[GIT-PULLS] [php-src] PR #23506: ext/openssl: allow openssl_sign()/openssl_verify() to sign/verify a precomputed digest
[email protected] (2akouwu)
| Newsgroups | php.git-pulls |
|---|---|
| Message-ID | <[email protected]> |
Pull Request: https://github.com/php/php-src/pull/23506
Author: 2akouwu
Fixes #23422.
## Root cause
`openssl_sign()` and `openssl_verify()` are implemented purely on top of OpenSSL's one-shot `EVP_DigestSign()`/`EVP_DigestVerify()` APIs. Those APIs always hash the buffer that is passed to them before performing the RSA/EC/etc. signature operation. That is correct when `$data` is the original message, but it means there is currently no way to sign or verify a digest that was already computed elsewhere (e.g. by a HSM, or incrementally via `hash_update()`/`hash_final()`): passing the digest as `$data` just hashes it a second time and produces `sign(SHA256(digest))` instead of `sign(digest)`.
## Why this fix
OpenSSL exposes exactly the primitive needed for this: `EVP_PKEY_sign_init()`/`EVP_PKEY_sign()` and `EVP_PKEY_verify_init()`/`EVP_PKEY_verify()` operate on a buffer *without* hashing it, as long as the expected digest algorithm is registered on the `EVP_PKEY_CTX` via `EVP_PKEY_CTX_set_signature_md()` (RSA needs this to build the correct PKCS#1 `DigestInfo` prefix / PSS MGF1 digest; for EC/DSA it is used to validate the digest length).
I added a new optional trailing parameter, `bool $data_is_digest = false`, to both `openssl_sign()` and `openssl_verify()`, defaulting to `false` so existing call sites are unaffected. When `true`, the implementation takes a separate branch that builds its own `EVP_PKEY_CTX` and drives the raw sign/verify primitives instead of `EVP_DigestSign`/`EVP_DigestVerify`. It reuses the existing `php_openssl_setup_rsa_padding()` and `php_openssl_setup_rsa_pss_salt_length()` static helpers unchanged, since they only operate on an `EVP_PKEY_CTX` and don't care how it was created, so padding/PSS-salt-length handling stays identical between both code paths.
I considered instead exposing this only through a brand-new function (e.g. `openssl_pkey_sign_digest()`), but extending the existing functions with a defaulted parameter is smaller, keeps all the existing algorithm/padding/salt-length validation and error handling in one place, and mirrors how `openssl_private_encrypt()`/`openssl_public_decrypt()` etc. already expose several signing-adjacent primitives as parameterized variants of one function rather than a family of near-duplicate functions.
For keys where OpenSSL doesn't support raw sign/verify at all (e.g. Ed25519, which only implements the one-shot digest-sign flow), `EVP_PKEY_sign_init()`/`EVP_PKEY_verify_init()` fail naturally and the existing `php_openssl_store_errors()` path surfaces that as a warning + `false`/`-1`, same as any other OpenSSL-level failure already handled by these functions.
`ext/openssl/openssl.stub.php` and the generated `ext/openssl/openssl_arginfo.h` were updated together (the stub hash comment was recomputed by hand since `gen_stub.php` requires a PHP CLI that isn't available in this sandbox); `UPGRADING` documents the new parameter.
## Testing
Added `ext/openssl/tests/gh23422.phpt`, which:
- Generates a throwaway RSA key with `openssl_pkey_new()` (no fixture files needed).
- Signs the same data once normally (`openssl_sign($data, ..., OPENSSL_ALGO_SHA256)`) and once as a precomputed digest (`openssl_sign(hash('sha256', $data, true), ..., OPENSSL_ALGO_SHA256, 0, ..., true)`), and asserts the two PKCS#1 v1.5 signatures are byte-identical — this is the core proof that signing a precomputed digest now produces the mathematically correct signature rather than double-hashing it.
- Verifies the digest-produced signature against both the digest and the original data via `openssl_verify(..., data_is_digest: true/false)`.
- Flips one byte of the digest and confirms verification now fails (`int(0)`) instead of silently succeeding.
- Repeats the sign/verify round trip with `OPENSSL_PKCS1_PSS_PADDING` to confirm padding/salt-length handling still applies on the new code path.
I could not execute the test suite in this sandbox (no PHP/build toolchain available, and the bundle doesn't include the pre-generated RSA key fixtures used by some of the pre-existing openssl_sign*/openssl_verify* tests), so the new test intentionally avoids depending on any fixture files and generates its own key. I traced the OpenSSL/PHP control flow by hand to confirm the expected output, which is documented as `--EXPECT--` in the phpt file.