Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace EE\Migration;

use EE;

class RepairSelfSignedRootCa extends Base {

/** @var string|null Root certificate before the repair, restored by down(). */
private $old_pem;

private $root_pem;

public function __construct() {
parent::__construct();
$this->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;
Comment thread
mrrobot47 marked this conversation as resolved.
} 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();
}
}
130 changes: 124 additions & 6 deletions src/helper/Site_Self_Signed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() );
}
Comment thread
mrrobot47 marked this conversation as resolved.

return true;
}

Expand All @@ -92,8 +103,8 @@ 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 ) ) );
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 );

Expand All @@ -102,6 +113,114 @@ 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. 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.
*
* @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 ) ) {
// 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-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 ) ) {
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();
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 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 || ! is_file( $this->trusted_copy ) || file_get_contents( $this->trusted_copy ) === file_get_contents( $this->root_pem ) ) {
return;
}
$this->trust_certificate( $this->root_pem );
}

/**
* Build the SSL config for the given URL.
*
Expand Down Expand Up @@ -137,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 ] );
}

/**
Expand All @@ -158,8 +277,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' );
}
}
Expand Down
6 changes: 6 additions & 0 deletions templates/config/self-signed-certs/openssl.conf.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions templates/config/self-signed-certs/v3.ext.mustache
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
authorityKeyIdentifier=keyid,issuer
subjectKeyIdentifier=hash
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[alt_names]
Expand Down
Loading