[php-src] PR #23506: ext/openssl: allow openssl_sign()/openssl_verify() to sign/verify a precomputed digest
| From: | 2akouwu | Date: | Sat, 29 Aug 2026 13:07:14 +0000 |
| Subject: | [php-src] PR #23506: ext/openssl: allow openssl_sign()/openssl_verify() to sign/verify a precomputed digest | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38200@lists.php.net to get a copy of this message | ||
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.