From a1695b3dcac5881059a80c3e439bfa784c0b4665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Sun, 12 Jul 2026 08:04:18 +0200 Subject: [PATCH 1/2] ext/openssl: allow constructing a public-only RSA key --- UPGRADING | 4 ++ ext/openssl/openssl.c | 5 +- ext/openssl/openssl_backend_v1.c | 9 +-- ext/openssl/openssl_backend_v3.c | 13 +++-- ext/openssl/php_openssl_backend.h | 2 +- .../tests/openssl_pkey_new_rsa_public.phpt | 57 +++++++++++++++++++ 6 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 ext/openssl/tests/openssl_pkey_new_rsa_public.phpt diff --git a/UPGRADING b/UPGRADING index 031b6751d853..912d53936cfc 100644 --- a/UPGRADING +++ b/UPGRADING @@ -360,6 +360,10 @@ PHP 8.6 UPGRADE NOTES - OpenSSL: . Output of openssl_x509_parse() contains criticalExtensions listing all critical certificate extensions. + . openssl_pkey_new() can now construct a public-only RSA key when only the + modulus "n" and public exponent "e" are given (without the private + exponent "d"), matching the behavior already available for DSA, DH and EC + keys. - PDO_DBLIB: . When using persistent connections, there is now a liveness check in the diff --git a/ext/openssl/openssl.c b/ext/openssl/openssl.c index e11ad5c3428c..6f1e67cfefa2 100644 --- a/ext/openssl/openssl.c +++ b/ext/openssl/openssl.c @@ -2465,11 +2465,12 @@ PHP_FUNCTION(openssl_pkey_new) if ((data = zend_hash_str_find(Z_ARRVAL_P(args), "rsa", sizeof("rsa")-1)) != NULL && Z_TYPE_P(data) == IS_ARRAY) { - pkey = php_openssl_pkey_init_rsa(data); + bool is_private; + pkey = php_openssl_pkey_init_rsa(data, &is_private); if (!pkey) { RETURN_FALSE; } - php_openssl_pkey_object_init(return_value, pkey, /* is_private */ true); + php_openssl_pkey_object_init(return_value, pkey, is_private); return; } else if ((data = zend_hash_str_find(Z_ARRVAL_P(args), "dsa", sizeof("dsa") - 1)) != NULL && Z_TYPE_P(data) == IS_ARRAY) { diff --git a/ext/openssl/openssl_backend_v1.c b/ext/openssl/openssl_backend_v1.c index 0c1ac6b955f6..fc407c18653a 100644 --- a/ext/openssl/openssl_backend_v1.c +++ b/ext/openssl/openssl_backend_v1.c @@ -78,14 +78,15 @@ EVP_PKEY_CTX *php_openssl_pkey_new_from_pkey(EVP_PKEY *pkey) return EVP_PKEY_CTX_new(pkey, NULL); } -static bool php_openssl_pkey_init_rsa_data(RSA *rsa, zval *data) +static bool php_openssl_pkey_init_rsa_data(RSA *rsa, zval *data, bool *is_private) { BIGNUM *n, *e, *d, *p, *q, *dmp1, *dmq1, *iqmp; OPENSSL_PKEY_SET_BN(data, n); OPENSSL_PKEY_SET_BN(data, e); OPENSSL_PKEY_SET_BN(data, d); - if (!n || !d || !RSA_set0_key(rsa, n, e, d)) { + *is_private = d != NULL; + if (!n || (!d && !e) || !RSA_set0_key(rsa, n, e, d)) { return false; } @@ -105,7 +106,7 @@ static bool php_openssl_pkey_init_rsa_data(RSA *rsa, zval *data) return true; } -EVP_PKEY *php_openssl_pkey_init_rsa(zval *data) +EVP_PKEY *php_openssl_pkey_init_rsa(zval *data, bool *is_private) { EVP_PKEY *pkey = EVP_PKEY_new(); if (!pkey) { @@ -120,7 +121,7 @@ EVP_PKEY *php_openssl_pkey_init_rsa(zval *data) return NULL; } - if (!php_openssl_pkey_init_rsa_data(rsa, data) || !EVP_PKEY_assign_RSA(pkey, rsa)) { + if (!php_openssl_pkey_init_rsa_data(rsa, data, is_private) || !EVP_PKEY_assign_RSA(pkey, rsa)) { php_openssl_store_errors(); EVP_PKEY_free(pkey); RSA_free(rsa); diff --git a/ext/openssl/openssl_backend_v3.c b/ext/openssl/openssl_backend_v3.c index 375c0104fac5..5ab700809a15 100644 --- a/ext/openssl/openssl_backend_v3.c +++ b/ext/openssl/openssl_backend_v3.c @@ -82,7 +82,7 @@ EVP_PKEY_CTX *php_openssl_pkey_new_from_pkey(EVP_PKEY *pkey) return EVP_PKEY_CTX_new_from_pkey(PHP_OPENSSL_LIBCTX, pkey, PHP_OPENSSL_PROPQ); } -EVP_PKEY *php_openssl_pkey_init_rsa(zval *data) +EVP_PKEY *php_openssl_pkey_init_rsa(zval *data, bool *is_private) { BIGNUM *n = NULL, *e = NULL, *d = NULL, *p = NULL, *q = NULL; BIGNUM *dmp1 = NULL, *dmq1 = NULL, *iqmp = NULL; @@ -100,12 +100,16 @@ EVP_PKEY *php_openssl_pkey_init_rsa(zval *data) OPENSSL_PKEY_SET_BN(data, dmq1); OPENSSL_PKEY_SET_BN(data, iqmp); - if (!ctx || !bld || !n || !d) { + *is_private = d != NULL; + + if (!ctx || !bld || !n || (!d && !e)) { goto cleanup; } OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_N, n); - OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_D, d); + if (d) { + OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_D, d); + } if (e) { OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_E, e); } @@ -130,8 +134,9 @@ EVP_PKEY *php_openssl_pkey_init_rsa(zval *data) goto cleanup; } + int selection = *is_private ? EVP_PKEY_KEYPAIR : EVP_PKEY_PUBLIC_KEY; if (EVP_PKEY_fromdata_init(ctx) <= 0 || - EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_KEYPAIR, params) <= 0) { + EVP_PKEY_fromdata(ctx, &pkey, selection, params) <= 0) { goto cleanup; } diff --git a/ext/openssl/php_openssl_backend.h b/ext/openssl/php_openssl_backend.h index fcbb8d42542f..80071c8d0d3b 100644 --- a/ext/openssl/php_openssl_backend.h +++ b/ext/openssl/php_openssl_backend.h @@ -326,7 +326,7 @@ void php_openssl_add_bn_to_array(zval *ary, const BIGNUM *bn, const char *name); EVP_PKEY_CTX *php_openssl_pkey_new_from_name(const char *name, int id); EVP_PKEY_CTX *php_openssl_pkey_new_from_pkey(EVP_PKEY *pkey); -EVP_PKEY *php_openssl_pkey_init_rsa(zval *data); +EVP_PKEY *php_openssl_pkey_init_rsa(zval *data, bool *is_private); EVP_PKEY *php_openssl_pkey_init_dsa(zval *data, bool *is_private); BIGNUM *php_openssl_dh_pub_from_priv(BIGNUM *priv_key, BIGNUM *g, BIGNUM *p); EVP_PKEY *php_openssl_pkey_init_dh(zval *data, bool *is_private); diff --git a/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt b/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt new file mode 100644 index 000000000000..2045ecd442fb --- /dev/null +++ b/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt @@ -0,0 +1,57 @@ +--TEST-- +openssl_pkey_new() can construct a public-only RSA key from n and e +--EXTENSIONS-- +openssl +--FILE-- + OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, +]); +$priv_details = openssl_pkey_get_details($private); +$n = $priv_details['rsa']['n']; +$e = $priv_details['rsa']['e']; + +$public = openssl_pkey_new(['rsa' => ['n' => $n, 'e' => $e]]); +var_dump($public instanceof OpenSSLAsymmetricKey); + +$pub_details = openssl_pkey_get_details($public); +var_dump($pub_details['type'] === OPENSSL_KEYTYPE_RSA); +var_dump($pub_details['bits']); +// A public-only key exposes n and e but not the private components. +var_dump(isset($pub_details['rsa']['n'])); +var_dump(isset($pub_details['rsa']['e'])); +var_dump(isset($pub_details['rsa']['d'])); +var_dump(strpos($pub_details['key'], 'BEGIN PUBLIC KEY') !== false); +var_dump($pub_details['rsa']['n'] === $n); +var_dump($pub_details['rsa']['e'] === $e); + +// The reconstructed public key can verify a signature made by the private key. +$data = 'The quick brown fox'; +openssl_sign($data, $signature, $private, OPENSSL_ALGO_SHA256); +var_dump(openssl_verify($data, $signature, $public, OPENSSL_ALGO_SHA256)); +var_dump(openssl_verify('tampered', $signature, $public, OPENSSL_ALGO_SHA256)); + +// It can also be used for public-key encryption. +openssl_public_encrypt('secret', $encrypted, $public); +openssl_private_decrypt($encrypted, $decrypted, $private); +var_dump($decrypted === 'secret'); + +// Missing e (and d) is still an error: a public key needs the exponent. +var_dump(@openssl_pkey_new(['rsa' => ['n' => $n]])); +?> +--EXPECT-- +bool(true) +bool(true) +int(2048) +bool(true) +bool(true) +bool(false) +bool(true) +bool(true) +bool(true) +int(1) +int(0) +bool(true) +bool(false) From c2ebfda47f5907403d39fa3dcfe7d193dd2b6164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Sun, 12 Jul 2026 08:33:33 +0200 Subject: [PATCH 2/2] adresss copilots review --- ext/openssl/openssl_backend_v1.c | 32 +++++++++++++++---- ext/openssl/openssl_backend_v3.c | 11 ++++--- .../tests/openssl_pkey_new_rsa_public.phpt | 22 +++++++++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/ext/openssl/openssl_backend_v1.c b/ext/openssl/openssl_backend_v1.c index fc407c18653a..08420759cb46 100644 --- a/ext/openssl/openssl_backend_v1.c +++ b/ext/openssl/openssl_backend_v1.c @@ -85,22 +85,42 @@ static bool php_openssl_pkey_init_rsa_data(RSA *rsa, zval *data, bool *is_privat OPENSSL_PKEY_SET_BN(data, n); OPENSSL_PKEY_SET_BN(data, e); OPENSSL_PKEY_SET_BN(data, d); - *is_private = d != NULL; - if (!n || (!d && !e) || !RSA_set0_key(rsa, n, e, d)) { + /* The modulus n and public exponent e form the public key and are always + * required; d is only present for a private key. */ + if (!n || !e || !RSA_set0_key(rsa, n, e, d)) { return false; } + /* n, e and d are now owned by rsa. */ + *is_private = d != NULL; + /* The factor and CRT components are meaningless without the private + * exponent d, so reject them rather than silently ignoring them. */ OPENSSL_PKEY_SET_BN(data, p); OPENSSL_PKEY_SET_BN(data, q); - if ((p || q) && !RSA_set0_factors(rsa, p, q)) { - return false; + if (p || q) { + if (!d) { + BN_free(p); + BN_free(q); + return false; + } + if (!RSA_set0_factors(rsa, p, q)) { + return false; + } } OPENSSL_PKEY_SET_BN(data, dmp1); OPENSSL_PKEY_SET_BN(data, dmq1); OPENSSL_PKEY_SET_BN(data, iqmp); - if ((dmp1 || dmq1 || iqmp) && !RSA_set0_crt_params(rsa, dmp1, dmq1, iqmp)) { - return false; + if (dmp1 || dmq1 || iqmp) { + if (!d) { + BN_free(dmp1); + BN_free(dmq1); + BN_free(iqmp); + return false; + } + if (!RSA_set0_crt_params(rsa, dmp1, dmq1, iqmp)) { + return false; + } } return true; diff --git a/ext/openssl/openssl_backend_v3.c b/ext/openssl/openssl_backend_v3.c index 5ab700809a15..1fec83efe257 100644 --- a/ext/openssl/openssl_backend_v3.c +++ b/ext/openssl/openssl_backend_v3.c @@ -100,19 +100,22 @@ EVP_PKEY *php_openssl_pkey_init_rsa(zval *data, bool *is_private) OPENSSL_PKEY_SET_BN(data, dmq1); OPENSSL_PKEY_SET_BN(data, iqmp); + /* The modulus n and public exponent e form the public key and are always + * required. The key is private if the private exponent d is provided. The + * remaining private components (p, q, dmp1, dmq1, iqmp) are meaningless + * without d, so reject them rather than silently building a public key that + * ignores them. */ *is_private = d != NULL; - if (!ctx || !bld || !n || (!d && !e)) { + if (!ctx || !bld || !n || !e || (!d && (p || q || dmp1 || dmq1 || iqmp))) { goto cleanup; } OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_N, n); + OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_E, e); if (d) { OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_D, d); } - if (e) { - OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_E, e); - } if (p) { OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_FACTOR1, p); } diff --git a/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt b/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt index 2045ecd442fb..764a537d4e24 100644 --- a/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt +++ b/ext/openssl/tests/openssl_pkey_new_rsa_public.phpt @@ -38,8 +38,22 @@ openssl_public_encrypt('secret', $encrypted, $public); openssl_private_decrypt($encrypted, $decrypted, $private); var_dump($decrypted === 'secret'); -// Missing e (and d) is still an error: a public key needs the exponent. -var_dump(@openssl_pkey_new(['rsa' => ['n' => $n]])); +// The key is flagged as public: it cannot be used where a private key is required. +var_dump(@openssl_sign($data, $ignored, $public, OPENSSL_ALGO_SHA256)); + +// n and e are always required; anything less is an error. +var_dump(@openssl_pkey_new(['rsa' => ['n' => $n]])); // missing e +var_dump(@openssl_pkey_new(['rsa' => ['e' => $e]])); // missing n +var_dump(@openssl_pkey_new(['rsa' => ['n' => $n, 'd' => $priv_details['rsa']['d']]])); // missing e + +// Private components without the private exponent d are rejected rather than +// silently building a mislabeled key. +var_dump(@openssl_pkey_new(['rsa' => [ + 'n' => $n, + 'e' => $e, + 'p' => $priv_details['rsa']['p'], + 'q' => $priv_details['rsa']['q'], +]])); ?> --EXPECT-- bool(true) @@ -55,3 +69,7 @@ int(1) int(0) bool(true) bool(false) +bool(false) +bool(false) +bool(false) +bool(false)