Skip to content

fix(ssl): keep the served key when a first certificate request fails - #506

Merged
mrrobot47 merged 5 commits into
EasyEngine:developfrom
mrrobot47:fix/ssl-first-request-keep-key
Sep 29, 2026
Merged

mrrobot47 merged 5 commits into
EasyEngine:developfrom
mrrobot47:fix/ssl-first-request-keep-key

Conversation

@mrrobot47

@mrrobot47 mrrobot47 commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Merge after #505.

Problem

ee site ssl-verify can go down the first-request path in executeFirstRequest() while the stored ACME order for that domain set is already finalized. EE then stored a new domain key and DN and called finalizeOrder(). For a valid order acmephp skips the CSR and LE returns the existing certificate, so nothing was issued, but EE deployed that certificate next to the new key.

The proxy then held a mismatched key/cert pair: nginx -t failed with key values mismatch, every later proxy reload was skipped for all sites, and a proxy restart would have taken them all down. The command still printed "Success: SSL verification completed." and exited 0.

The same function also replaced the stored key before checking that the order exists, and a 403 from LE at finalize ended in an uncaught PHP fatal with the key already replaced.

Fix

  • The order is looked up before the stored key is touched.
  • The previous key pair and DN are restored if the request fails, including a failure while storing the new key or DN, with a warning. The warning says the current certificate is kept only when the domain has one, so a failed first issuance doesn't claim it.
  • The certificate LE returns must match the new key, otherwise it is treated as a failure.
  • moveCertsToNginxProxy() refuses to deploy a certificate that doesn't match its key.
  • A 403 from LE at finalize now gives a warning with LE's reason instead of a PHP fatal.
  • Exception logs in ee.log no longer contain key material, on both the first-request and the renewal path.

Testing

  • Already-finalized order: warning "the returned certificate does not match the new domain key", the stored key, DN and proxy files are unchanged, nginx -t passes, and the served certificate is the same. Without the fix the same setup breaks the proxy.
  • 403 at finalize: warning with LE's reason, no fatal, key and DN unchanged.
  • A normal Let's Encrypt site create still issues and deploys a matching pair.
  • php -l passes on PHP 7.4 and 8.5.

executeFirstRequest() stored a new domain key pair and DN before it looked up the order and finalized it. A missing order ("has not yet been authorized"), a refused finalize (403, uncaught) or an order that LE had already finalized left acme-conf with a key that no longer matched the served certificate. In the last case acmephp's finalizeOrder() skips the CSR and returns the order's existing certificate, so the new key and the old certificate were deployed together and nginx -t failed for the whole proxy.

Look up the order first, keep the previous key pair and DN, check that the returned certificate matches the new key, and on any failure restore both, warn and return false. moveCertsToNginxProxy() also refuses to deploy a mismatched pair.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Repository mutations occur before the rollback-protected block, allowing failures to leave inconsistent ACME state.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
What changed in this PR

Prevents failed ACME requests from replacing the key for the certificate currently served by nginx.

Changes:

  • Restores prior ACME key and DN after request failures.
  • Validates certificate/private-key pairs before storage and deployment.
  • Converts finalize failures into warnings.
File Description
src/​helper/​Site_Letsencrypt.php Adds ACME rollback and certificate/key validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/helper/Site_Letsencrypt.php Outdated
Comment thread src/helper/Site_Letsencrypt.php Outdated
The new key and DN were stored before the try block, so a failure while storing them (for example a full disk) skipped the restore and left acme-conf with a key that doesn't match the served certificate. Start the try before the key is generated, and log the error before restoring, so a restore that fails too doesn't hide it.
A failed first request on a new site (site create, or update --ssl=le on a site without SSL) also printed "The current certificate is kept.", although there was no certificate. Add that sentence only when a certificate is stored for the domain.
…fails

print_r() of the exception also prints its trace arguments when zend.exception_ignore_args is off (the PHP default, and always on PHP 7.2/7.3). The finalizeOrder() frame holds the CSR with the new private key, so the key was written to ee.log, which every EE::debug() reaches. Log the exception as a string instead: message, location and a trace without argument contents.
executeRenewal() logged the caught exception with print_r(). When zend.exception_ignore_args is off (the PHP default, and the easyengine/php* images), that prints the trace arguments, and the finalizeOrder() frame holds the CSR with the live domain private key, so the key went to ee.log through EE::debug(). Walking every object in the trace can also exhaust memory. Log the exception as a string instead, as executeFirstRequest() already does.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Rollback remains incomplete and exception traces may still expose private-key material.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Certificate persistence is outside rollback transaction

src/​helper/​Site_Letsencrypt.php:630

The rollback scope ends before storeDomainCertificate(). That method writes several files sequentially; if it fails before replacing the full-chain file, the new key remains stored beside the old certificate and the exception escapes, recreating the inconsistent repository state this change is intended to prevent. Certificate persistence needs to participate in the same transaction/rollback, including restoring any partially replaced certificate files.

Comment thread src/helper/Site_Letsencrypt.php
Comment thread src/helper/Site_Letsencrypt.php
@mrrobot47

Copy link
Copy Markdown
Member Author

On the finding in the last review overview (certificate persistence outside the rollback, Site_Letsencrypt.php:630): this needs a file write to fail inside storeDomainCertificate(), and even then the served pair is safe. moveCertsToNginxProxy() isn't reached, and it refuses any key/certificate pair that doesn't match, so the proxy keeps its current files and nginx -t keeps passing. The next renewal signs a CSR with the stored key, so the ACME state becomes consistent again. Making the certificate files part of the rollback means backing up and restoring each of them, which is out of scope here; it's tracked as a follow-up.

@mrrobot47
mrrobot47 merged commit e6a52d8 into EasyEngine:develop Sep 29, 2026
1 of 5 checks passed
@mrrobot47
mrrobot47 deleted the fix/ssl-first-request-keep-key branch September 30, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants