[php-src] Issue #22702: Allow constructing a public-only RSA key with openssl_pkey_new()

[email protected] (JanTvrdik) Sun, 12 Jul 2026 05:29:07 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22702
Author: JanTvrdik

### Description

`openssl_pkey_new()` can construct a **public** key directly from raw parameters for every asymmetric key type **except RSA**:

- `['ec' => ['curve_name' => …, 'x' => …, 'y' => …]]` → public EC key ✅
- `['ed25519' => ['pub_key' => …]]` / `['ed448' => …]` → public EdDSA key ✅
- `['dsa' | 'dh' => ['pub_key' => …]]` → public key ✅
- `['rsa' => ['n' => …, 'e' => …]]` → ❌ a private exponent `d` is mandatory, and the result is always flagged **private**

So there is no way to obtain an RSA public key object from `n` and `e` that `openssl_verify()` will accept:

```php
// (1) Without d — construction fails outright:
var_dump(openssl_pkey_new(['rsa' => ['n' => $n, 'e' => $e]])); // bool(false)

// (2) With a dummy d — builds, but is flagged private, so verify() refuses it:
$k = openssl_pkey_new(['rsa' => ['n' => $n, 'e' => $e, 'd' => "\x00"]]);
openssl_verify($msg, $sig, $k, OPENSSL_ALGO_SHA256);
// Warning: openssl_verify(): Don't know how to get public key from this private key
// Warning: openssl_verify(): Supplied key param cannot be coerced into a public key
```

The only workarounds today are to build a throwaway "private" key with a placeholder `d`, then round-trip it back out via `openssl_pkey_get_details($k)['key']` and reload with `openssl_pkey_get_public()`, or to hand-encode a DER SubjectPublicKeyInfo in userland. Both are awkward for something every other key type supports natively.

### Why this is worth adding

**a) Consistency — RSA is the only outlier.** In `ext/openssl`, `php_openssl_pkey_init_dsa()`, `_dh()`, `_ec()`, and the curve-25519/448 path all take an `is_private` out-parameter, derive it from whether private material was supplied, and build public-only keys just fine. `php_openssl_pkey_init_rsa()` alone (i) hard-requires `d`, (ii) never computes `is_private`, and (iii) its caller in `openssl_pkey_new()` hardcodes `is_private = true`. Bringing RSA in line with the others is a small, localized change.

**b) WebAuthn / COSE key conversion.** WebAuthn credential public keys arrive as COSE_Key maps (RFC 9052; RSA parameters per RFC 8230) — i.e. raw `n` and `e` bytes for RS256. Relying parties need to turn those into an OpenSSL key purely to *verify* assertion signatures. For EC2 (ES256) and OKP (EdDSA) keys this is already a clean one-liner via `openssl_pkey_new()`; for RSA it forces the dummy-`d` PEM round-trip above or a hand-rolled ASN.1 encoder. Public-only RSA construction would let libraries handle all three COSE key types uniformly, with zero userland ASN.1.

### Suggested implementation

Mirror the existing `init_dsa`/`init_ec` pattern:

- give `php_openssl_pkey_init_rsa()` a `bool *is_private` out-param;
- make `d` optional (require `n` and `e`), push `OSSL_PKEY_PARAM_RSA_D` only when present, and set `*is_private = (d != NULL)`;
- select `EVP_PKEY_PUBLIC_KEY` vs `EVP_PKEY_KEYPAIR` accordingly (v3 backend), and pass `d == NULL` through to `RSA_set0_key()` (v1 backend, which already permits it);
- have the `rsa` branch of `openssl_pkey_new()` pass the flag to `php_openssl_pkey_object_init()`, exactly like the `dsa`/`ec` branches.

This is purely additive — keys constructed *with* `d` stay flagged private, so no BC break. Happy to send a PR with tests if this is welcome.

### PHP Version

master (8.5-dev); behaviour also confirmed on 8.4.23.