From ea1de9d45f93cbf552b597e401b67228e60aa82b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 08:41:47 +0530 Subject: [PATCH 1/2] fix(ssl): mark the self-signed root as a CA and repair existing roots The root CA config had no extensions. OpenSSL < 3.2 then issues a v1 root, which is accepted as a trust anchor, but OpenSSL >= 3.2 (Ubuntu 26.04, Debian 13) issues a v3 root without basicConstraints CA:TRUE, so `openssl verify`, curl and browsers reject every self-signed site certificate (error 79, invalid CA certificate). - Issue the root with a critical basicConstraints CA:TRUE, keyUsage keyCertSign/cRLSign and a key identifier; site certificates get extendedKeyUsage serverAuth and a key identifier. - Existing v3 roots without CA:TRUE are re-issued with the same key and subject, by a container migration and again on the next `--ssl=self` site. Site certificates name their root by key id, so they stay valid and are not re-signed; the new root only replaces the old one after every site certificate signed by its key verifies against it. The system trust store copy is refreshed. - v1 roots are left alone: they work, their site certificates name the root by issuer and serial number, and replacing them would invalidate the copies users imported into browsers. --- ...ite-command_repair_self_signed_root_ca.php | 59 +++++++++ src/helper/Site_Self_Signed.php | 117 +++++++++++++++++- .../self-signed-certs/openssl.conf.mustache | 6 + .../config/self-signed-certs/v3.ext.mustache | 2 + 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 migrations/container/20261001120000_site-command_repair_self_signed_root_ca.php diff --git a/migrations/container/20261001120000_site-command_repair_self_signed_root_ca.php b/migrations/container/20261001120000_site-command_repair_self_signed_root_ca.php new file mode 100644 index 00000000..f283720f --- /dev/null +++ b/migrations/container/20261001120000_site-command_repair_self_signed_root_ca.php @@ -0,0 +1,59 @@ +root_pem = EE_SERVICE_DIR . '/nginx-proxy/self-signed-certs/rootCA.pem'; + + if ( $this->is_first_execution || ! $this->fs->exists( $this->root_pem ) ) { + $this->skip_this_migration = true; + } + } + + /** + * Re-issue a self-signed root CA that lacks basicConstraints CA:TRUE (generated with OpenSSL >= 3.2). + */ + public function up() { + + if ( $this->skip_this_migration ) { + EE::debug( 'Skipping the self-signed root CA repair: not needed.' ); + + return; + } + + $old_pem = file_get_contents( $this->root_pem ); + try { + if ( ( new \EE\Site\Type\Site_Self_signed() )->repair_root_cert() ) { + $this->old_pem = $old_pem; + } else { + EE::debug( 'Self-signed root CA needs no repair.' ); + } + } catch ( \Throwable $e ) { + // Not worth failing the upgrade over: the next --ssl=self site retries the repair. + EE::warning( 'Could not repair the self-signed root CA: ' . $e->getMessage() ); + } + } + + /** + * Restore the previous root certificate (same key, so site certificates are unaffected). + */ + public function down() { + + if ( null === $this->old_pem ) { + return; + } + + $this->fs->dumpFile( $this->root_pem, $this->old_pem ); + ( new \EE\Site\Type\Site_Self_signed() )->retrust_root_cert(); + } +} diff --git a/src/helper/Site_Self_Signed.php b/src/helper/Site_Self_Signed.php index 206c3a9a..a1e5bece 100644 --- a/src/helper/Site_Self_Signed.php +++ b/src/helper/Site_Self_Signed.php @@ -38,6 +38,11 @@ class Site_Self_signed { */ private $password; + /** + * @var string $trusted_copy Copy of the root certificate in the system trust store (Linux). + */ + private $trusted_copy = '/usr/local/share/ca-certificates/easyengine.crt'; + public function __construct() { $this->fs = new Filesystem(); @@ -83,6 +88,12 @@ private function maybe_gen_root_cert() { $root_conf = $this->conf_dir . '/rootCA.conf'; if ( $this->fs->exists( $this->root_pem ) && ! empty( $this->password ) ) { + try { + $this->repair_root_cert(); + } catch ( \Throwable $e ) { + EE::warning( 'Could not repair the self-signed root CA: ' . $e->getMessage() ); + } + return true; } @@ -93,7 +104,7 @@ private function maybe_gen_root_cert() { // Escape every interpolated value: these strings run through /bin/sh -c, so an // unescaped password (or path) could inject an attacker subshell as root. EE::exec( sprintf( 'openssl genrsa -des3 -passout pass:%s -out %s 2048', escapeshellarg( $this->password ), escapeshellarg( $this->root_key ) ) ); - EE::exec( sprintf( 'openssl req -x509 -new -nodes -key %s -sha256 -days 36500 -passin pass:%s -out %s -config %s', escapeshellarg( $this->root_key ), escapeshellarg( $this->password ), escapeshellarg( $this->root_pem ), escapeshellarg( $root_conf ) ) ); + $this->issue_root_cert( $this->root_pem, $root_conf ); $this->trust_certificate( $this->root_pem ); @@ -102,6 +113,107 @@ private function maybe_gen_root_cert() { $this->fs->remove( $root_conf ); } + /** + * Self-sign the root certificate with the existing root key and the v3_ca extensions. + * + * @param string $out_path Where to write the certificate. + * @param string $root_conf Root openssl config built from openssl.conf.mustache. + * + * @return bool + */ + private function issue_root_cert( $out_path, $root_conf ) { + // Without explicit CA extensions, OpenSSL >= 3.2 issues a v3 root lacking basicConstraints CA:TRUE, which nothing accepts as a CA. + return EE::exec( sprintf( 'openssl req -x509 -new -nodes -key %s -sha256 -days 36500 -passin pass:%s -out %s -config %s -extensions v3_ca', escapeshellarg( $this->root_key ), escapeshellarg( $this->password ), escapeshellarg( $out_path ), escapeshellarg( $root_conf ) ), false, false, [ $this->password ] ); + } + + /** + * Whether the root certificate is a v3 certificate that is not marked as a CA. + * + * v1 roots (issued by OpenSSL < 3.2) are accepted as trust anchors as they are, and their leaves name the + * root by issuer and serial number, so re-issuing them would break every existing leaf. + * + * @param string $pem Root certificate. + * + * @return bool + */ + public static function root_cert_needs_repair( $pem ) { + $cert = openssl_x509_parse( $pem ); + if ( false === $cert || empty( $cert['version'] ) ) { + return false; + } + $constraints = isset( $cert['extensions']['basicConstraints'] ) ? $cert['extensions']['basicConstraints'] : ''; + + return false === strpos( $constraints, 'CA:TRUE' ); + } + + /** + * Re-issue a root certificate that is not marked as a CA, keeping its key and subject. + * + * Existing leaves identify the root by key id, so they verify against the new root unchanged and no site + * certificate is re-signed. Nothing is replaced unless every leaf signed by the root key verifies. + * + * @return bool True if the root certificate was replaced. + * @throws \Exception When the new root can't be issued or doesn't validate the existing leaves. + */ + public function repair_root_cert() { + if ( empty( $this->password ) || ! $this->fs->exists( [ $this->root_pem, $this->root_key ] ) ) { + return false; + } + + $old_pem = file_get_contents( $this->root_pem ); + if ( ! self::root_cert_needs_repair( $old_pem ) ) { + return false; + } + + EE::log( 'Re-issuing the self-signed root CA with CA:TRUE (same key, existing site certificates stay valid).' ); + + $root_conf = $this->conf_dir . '/rootCA.conf'; + $new_pem = $this->conf_dir . '/rootCA.pem.new'; + $this->build_certificate_conf( $root_conf, 'EasyEngine' ); + try { + if ( ! $this->issue_root_cert( $new_pem, $root_conf ) || ! $this->fs->exists( $new_pem ) ) { + throw new \Exception( 'openssl could not issue the new root certificate.' ); + } + + $old = openssl_x509_parse( $old_pem ); + $new = openssl_x509_parse( file_get_contents( $new_pem ) ); + if ( false === $new || $old['subject'] !== $new['subject'] || self::root_cert_needs_repair( file_get_contents( $new_pem ) ) ) { + throw new \Exception( 'the new root certificate does not match the old one.' ); + } + + foreach ( glob( EE_ROOT_DIR . '/services/nginx-proxy/certs/*.crt' ) ?: [] as $leaf ) { + if ( 1 !== openssl_x509_verify( file_get_contents( $leaf ), $old_pem ) ) { + continue; + } + if ( ! EE::exec( sprintf( 'openssl verify -CAfile %s %s', escapeshellarg( $new_pem ), escapeshellarg( $leaf ) ) ) ) { + throw new \Exception( "$leaf does not verify against the new root certificate." ); + } + } + + $this->fs->rename( $new_pem, $this->root_pem, true ); + } finally { + $this->fs->remove( [ $root_conf, $new_pem ] ); + } + + $this->retrust_root_cert(); + EE::log( "If you imported the old EasyEngine root CA into a browser, import {$this->root_pem} again." ); + + return true; + } + + /** + * Refresh the trusted copy of the root certificate after it was replaced. + */ + public function retrust_root_cert() { + if ( IS_DARWIN ) { + // The keychain needs an interactive sudo, which a migration can't ask for. + EE::warning( "Trust the new EasyEngine root CA: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {$this->root_pem}" ); + + return; + } + $this->trust_certificate( $this->root_pem ); + } + /** * Build the SSL config for the given URL. * @@ -158,8 +270,7 @@ private function trust_certificate( $crt_path ) { 'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s', escapeshellarg( $crt_path ) ) ); } else { - $cert_path = '/usr/local/share/ca-certificates'; - $this->fs->copy( $this->root_pem, '/usr/local/share/ca-certificates/easyengine.crt' ); + $this->fs->copy( $this->root_pem, $this->trusted_copy, true ); EE::exec( 'update-ca-certificates' ); } } diff --git a/templates/config/self-signed-certs/openssl.conf.mustache b/templates/config/self-signed-certs/openssl.conf.mustache index f166deb2..8441899e 100644 --- a/templates/config/self-signed-certs/openssl.conf.mustache +++ b/templates/config/self-signed-certs/openssl.conf.mustache @@ -12,3 +12,9 @@ O=EasyEngine OU=EasyEngine emailAddress=support@easyengine.io CN=EE_DOMAIN + +[v3_ca] +basicConstraints = critical, CA:TRUE +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always diff --git a/templates/config/self-signed-certs/v3.ext.mustache b/templates/config/self-signed-certs/v3.ext.mustache index ee5371b8..562c96e6 100644 --- a/templates/config/self-signed-certs/v3.ext.mustache +++ b/templates/config/self-signed-certs/v3.ext.mustache @@ -1,6 +1,8 @@ authorityKeyIdentifier=keyid,issuer +subjectKeyIdentifier=hash basicConstraints=CA:FALSE keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +extendedKeyUsage = serverAuth subjectAltName = @alt_names [alt_names] From 44d66f2e46a77da0659a905f72ca69f73251da4e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 08:50:30 +0530 Subject: [PATCH 2/2] fix(ssl): refresh only an existing trust store copy and keep the CA password out of ee.log The root CA repair re-trusts the root only where a trusted copy already exists (an admin may have removed it on purpose), and completes a refresh that failed after the root was replaced on the next run. The macOS keychain hint is only printed after an actual repair, the repair's temporary files get per-process names, and the openssl commands that take the root key password no longer write it to ee.log. --- src/helper/Site_Self_Signed.php | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/helper/Site_Self_Signed.php b/src/helper/Site_Self_Signed.php index a1e5bece..9cf9751a 100644 --- a/src/helper/Site_Self_Signed.php +++ b/src/helper/Site_Self_Signed.php @@ -103,7 +103,7 @@ private function maybe_gen_root_cert() { // Escape every interpolated value: these strings run through /bin/sh -c, so an // unescaped password (or path) could inject an attacker subshell as root. - EE::exec( sprintf( 'openssl genrsa -des3 -passout pass:%s -out %s 2048', escapeshellarg( $this->password ), escapeshellarg( $this->root_key ) ) ); + EE::exec( sprintf( 'openssl genrsa -des3 -passout pass:%s -out %s 2048', escapeshellarg( $this->password ), escapeshellarg( $this->root_key ) ), false, false, [ $this->password ] ); $this->issue_root_cert( $this->root_pem, $root_conf ); $this->trust_certificate( $this->root_pem ); @@ -129,8 +129,9 @@ private function issue_root_cert( $out_path, $root_conf ) { /** * Whether the root certificate is a v3 certificate that is not marked as a CA. * - * v1 roots (issued by OpenSSL < 3.2) are accepted as trust anchors as they are, and their leaves name the - * root by issuer and serial number, so re-issuing them would break every existing leaf. + * v1 roots (issued by OpenSSL < 3.2) are accepted as trust anchors as they are. Their leaves name the root by + * issuer and serial number, so a re-issued root would either break them or reuse the serial of the copies users + * imported into browsers. * * @param string $pem Root certificate. * @@ -162,13 +163,16 @@ public function repair_root_cert() { $old_pem = file_get_contents( $this->root_pem ); if ( ! self::root_cert_needs_repair( $old_pem ) ) { + // Also completes a repair whose trust store refresh failed. + $this->retrust_root_cert(); + return false; } EE::log( 'Re-issuing the self-signed root CA with CA:TRUE (same key, existing site certificates stay valid).' ); - $root_conf = $this->conf_dir . '/rootCA.conf'; - $new_pem = $this->conf_dir . '/rootCA.pem.new'; + $root_conf = $this->conf_dir . '/rootCA-repair.' . getmypid() . '.conf'; + $new_pem = $this->conf_dir . '/rootCA.pem.' . getmypid() . '.new'; $this->build_certificate_conf( $root_conf, 'EasyEngine' ); try { if ( ! $this->issue_root_cert( $new_pem, $root_conf ) || ! $this->fs->exists( $new_pem ) ) { @@ -196,19 +200,22 @@ public function repair_root_cert() { } $this->retrust_root_cert(); + if ( IS_DARWIN ) { + // The keychain needs an interactive sudo, which a migration can't ask for. + EE::warning( "Trust the new EasyEngine root CA: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {$this->root_pem}" ); + } EE::log( "If you imported the old EasyEngine root CA into a browser, import {$this->root_pem} again." ); return true; } /** - * Refresh the trusted copy of the root certificate after it was replaced. + * Refresh the system trust store's copy of the root certificate (Linux) when it is outdated. + * + * A removed copy stays removed: an admin may have stopped trusting the root on purpose. */ public function retrust_root_cert() { - if ( IS_DARWIN ) { - // The keychain needs an interactive sudo, which a migration can't ask for. - EE::warning( "Trust the new EasyEngine root CA: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {$this->root_pem}" ); - + if ( IS_DARWIN || ! is_file( $this->trusted_copy ) || file_get_contents( $this->trusted_copy ) === file_get_contents( $this->root_pem ) ) { return; } $this->trust_certificate( $this->root_pem ); @@ -249,10 +256,10 @@ private function generate_certificate( $key_path, $crt_path, $csr_path, $conf_pa // Paths here derive from the (unvalidated) site name and run through /bin/sh -c, // so escape every interpolated value to prevent shell injection via the site name. - EE::exec( sprintf( 'openssl req -new -sha256 -nodes -passout pass:%s -out %s -newkey rsa:2048 -keyout %s -config %s', escapeshellarg( $this->password ), escapeshellarg( $csr_path ), escapeshellarg( $key_path ), escapeshellarg( $conf_path ) ) ); + EE::exec( sprintf( 'openssl req -new -sha256 -nodes -passout pass:%s -out %s -newkey rsa:2048 -keyout %s -config %s', escapeshellarg( $this->password ), escapeshellarg( $csr_path ), escapeshellarg( $key_path ), escapeshellarg( $conf_path ) ), false, false, [ $this->password ] ); $serial_string = $this->fs->exists( $this->root_srl ) ? sprintf( '-CAserial %s', escapeshellarg( $this->root_srl ) ) : sprintf( '-CAcreateserial -CAserial %s', escapeshellarg( $this->root_srl ) ); - EE::exec( sprintf( 'openssl x509 -req -in %s -CA %s -CAkey %s %s -passin pass:%s -out %s -days 36500 -sha256 -extfile %s', escapeshellarg( $csr_path ), escapeshellarg( $this->root_pem ), escapeshellarg( $this->root_key ), $serial_string, escapeshellarg( $this->password ), escapeshellarg( $crt_path ), escapeshellarg( $v3_path ) ) ); + EE::exec( sprintf( 'openssl x509 -req -in %s -CA %s -CAkey %s %s -passin pass:%s -out %s -days 36500 -sha256 -extfile %s', escapeshellarg( $csr_path ), escapeshellarg( $this->root_pem ), escapeshellarg( $this->root_key ), $serial_string, escapeshellarg( $this->password ), escapeshellarg( $crt_path ), escapeshellarg( $v3_path ) ), false, false, [ $this->password ] ); } /**