diff --git a/doc/06-config.md b/doc/06-config.md index 59390ea42..f6c2da532 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -55,9 +55,15 @@ php_openssl extension in php.ini. ## cafile -A way to set the path to the openssl CA file. In PHP 5.6+ you should rather -set this via openssl.cafile in php.ini, although PHP 5.6+ should be able to -detect your system CA file automatically. +Location of Certificate Authority file on local filesystem. In PHP 5.6+ you +should rather set this via openssl.cafile in php.ini, although PHP 5.6+ should +be able to detect your system CA file automatically. + +## capath + +If cafile is not specified or if the certificate is not found there, the +directory pointed to by capath is searched for a suitable certificate. +capath must be a correctly hashed certificate directory. ## http-basic diff --git a/res/composer-schema.json b/res/composer-schema.json index 966b19b86..9d36b6721 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -149,6 +149,10 @@ "type": "string", "description": "A way to set the path to the openssl CA file. In PHP 5.6+ you should rather set this via openssl.cafile in php.ini, although PHP 5.6+ should be able to detect your system CA file automatically." }, + "capath": { + "type": "string", + "description": "If cafile is not specified or if the certificate is not found there, the directory pointed to by capath is searched for a suitable certificate. capath must be a correctly hashed certificate directory." + }, "http-basic": { "type": "object", "description": "A hash of domain name => {\"username\": \"...\", \"password\": \"...\"}.", diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 8378bc5b4..0bf8f97d5 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -333,6 +333,10 @@ EOT function ($val) { return file_exists($val) && is_readable($val); }, function ($val) { return $val === 'null' ? null : $val; } ), + 'capath' => array( + function ($val) { return is_dir($val) && is_readable($val); }, + function ($val) { return $val === 'null' ? null : $val; } + ), 'github-expose-hostname' => array($booleanValidator, $booleanNormalizer), ); $multiConfigValues = array( diff --git a/src/Composer/Config.php b/src/Composer/Config.php index c7d8efa8e..a1b0246b9 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -47,6 +47,7 @@ class Config 'github-domains' => array('github.com'), 'disable-tls' => false, 'cafile' => null, + 'capath' => null, 'github-expose-hostname' => true, 'gitlab-domains' => array('gitlab.com'), 'store-auths' => 'prompt', @@ -179,6 +180,7 @@ class Config case 'cache-repo-dir': case 'cache-vcs-dir': case 'cafile': + case 'capath': // convert foo-bar to COMPOSER_FOO_BAR and check if it exists since it overrides the local config $env = 'COMPOSER_' . strtoupper(strtr($key, '-', '_')); diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 60431229b..e6718278f 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -590,9 +590,12 @@ class Factory $remoteFilesystemOptions = array(); if ($disableTls === false) { if ($config && $config->get('cafile')) { - $remoteFilesystemOptions = array('ssl' => array('cafile' => $config->get('cafile'))); + $remoteFilesystemOptions['ssl']['cafile'] = $config->get('cafile'); } - $remoteFilesystemOptions = array_merge_recursive($remoteFilesystemOptions, $options); + if ($config && $config->get('capath')) { + $remoteFilesystemOptions['ssl']['capath'] = $config->get('capath'); + } + $remoteFilesystemOptions = array_replace_recursive($remoteFilesystemOptions, $options); } try { $remoteFilesystem = new RemoteFilesystem($io, $config, $remoteFilesystemOptions, $disableTls); diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 4754e304b..9a8a25d81 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -54,15 +54,7 @@ class RemoteFilesystem // Setup TLS options // The cafile option can be set via config.json if ($disableTls === false) { - $this->options = $this->getTlsDefaults(); - if (isset($options['ssl']['cafile']) - && ( - !is_readable($options['ssl']['cafile']) - || !$this->validateCaFile($options['ssl']['cafile']) - ) - ) { - throw new TransportException('The configured cafile was not valid or could not be read.'); - } + $this->options = $this->getTlsDefaults($options); } else { $this->disableTls = true; } @@ -575,7 +567,12 @@ class RemoteFilesystem return $options; } - private function getTlsDefaults() + /** + * @param array $options + * + * @return array + */ + private function getTlsDefaults(array $options) { $ciphers = implode(':', array( 'ECDHE-RSA-AES128-GCM-SHA256', @@ -622,7 +619,7 @@ class RemoteFilesystem * * cafile or capath can be overridden by passing in those options to constructor. */ - $options = array( + $defaults = array( 'ssl' => array( 'ciphers' => $ciphers, 'verify_peer' => true, @@ -631,80 +628,86 @@ class RemoteFilesystem ) ); + if (isset($options['ssl'])) { + $defaults['ssl'] = array_replace_recursive($defaults['ssl'], $options['ssl']); + } + /** * Attempt to find a local cafile or throw an exception if none pre-set * The user may go download one if this occurs. */ - if (!isset($this->options['ssl']['cafile'])) { + if (!isset($defaults['ssl']['cafile']) && !isset($defaults['ssl']['capath'])) { $result = $this->getSystemCaRootBundlePath(); - if ($result) { - if (preg_match('{^phar://}', $result)) { - $targetPath = rtrim(sys_get_temp_dir(), '\\/') . '/composer-cacert.pem'; - - // use stream_copy_to_stream instead of copy - // to work around https://bugs.php.net/bug.php?id=64634 - $source = fopen($result, 'r'); - $target = fopen($targetPath, 'w+'); - stream_copy_to_stream($source, $target); - fclose($source); - fclose($target); - unset($source, $target); - - $options['ssl']['cafile'] = $targetPath; - } else { - if (is_dir($result)) { - $options['ssl']['capath'] = $result; - } elseif ($result) { - $options['ssl']['cafile'] = $result; - } + + if (preg_match('{^phar://}', $result)) { + $hash = hash_file('sha256', $result); + $targetPath = rtrim(sys_get_temp_dir(), '\\/') . '/composer-cacert-' . $hash . '.pem'; + + if (!file_exists($targetPath) || $hash !== hash_file('sha256', $targetPath)) { + $this->streamCopy($result, $targetPath); + chmod($targetPath, 0666); } + + $defaults['ssl']['cafile'] = $targetPath; + } elseif (is_dir($result)) { + $defaults['ssl']['capath'] = $result; } else { - throw new TransportException('A valid cafile could not be located automatically.'); + $defaults['ssl']['cafile'] = $result; } } + if (isset($defaults['ssl']['cafile']) && (!is_readable($defaults['ssl']['cafile']) || !$this->validateCaFile($defaults['ssl']['cafile']))) { + throw new TransportException('The configured cafile was not valid or could not be read.'); + } + + if (isset($defaults['ssl']['capath']) && (!is_dir($defaults['ssl']['capath']) || !is_readable($defaults['ssl']['capath']))) { + throw new TransportException('The configured capath was not valid or could not be read.'); + } + /** * Disable TLS compression to prevent CRIME attacks where supported. */ if (PHP_VERSION_ID >= 50413) { - $options['ssl']['disable_compression'] = true; + $defaults['ssl']['disable_compression'] = true; } - return $options; + return $defaults; } /** - * This method was adapted from Sslurp. - * https://github.com/EvanDotPro/Sslurp - * - * (c) Evan Coury - * - * For the full copyright and license information, please see below: - * - * Copyright (c) 2013, Evan Coury - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ + * This method was adapted from Sslurp. + * https://github.com/EvanDotPro/Sslurp + * + * (c) Evan Coury + * + * For the full copyright and license information, please see below: + * + * Copyright (c) 2013, Evan Coury + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @return string + */ private function getSystemCaRootBundlePath() { static $caPath = null; @@ -721,6 +724,11 @@ class RemoteFilesystem return $caPath = $envCertFile; } + $configured = ini_get('openssl.cafile'); + if ($configured && strlen($configured) > 0 && is_readable($configured) && $this->validateCaFile($configured)) { + return $caPath = $configured; + } + $caBundlePaths = array( '/etc/pki/tls/certs/ca-bundle.crt', // Fedora, RHEL, CentOS (ca-certificates package) '/etc/ssl/certs/ca-certificates.crt', // Debian, Ubuntu, Gentoo, Arch Linux (ca-certificates package) @@ -732,14 +740,8 @@ class RemoteFilesystem '/usr/share/ssl/certs/ca-bundle.crt', // Really old RedHat? '/etc/ssl/cert.pem', // OpenBSD '/usr/local/etc/ssl/cert.pem', // FreeBSD 10.x - __DIR__.'/../../../res/cacert.pem', // Bundled with Composer ); - $configured = ini_get('openssl.cafile'); - if ($configured && strlen($configured) > 0 && is_readable($configured) && $this->validateCaFile($configured)) { - return $caPath = $configured; - } - foreach ($caBundlePaths as $caBundle) { if (@is_readable($caBundle) && $this->validateCaFile($caBundle)) { return $caPath = $caBundle; @@ -753,9 +755,14 @@ class RemoteFilesystem } } - return $caPath = false; + return $caPath = __DIR__.'/../../../res/cacert.pem'; // Bundled with Composer, last resort } + /** + * @param string $filename + * + * @return bool + */ private function validateCaFile($filename) { if ($this->io->isDebug()) { @@ -775,4 +782,22 @@ class RemoteFilesystem return (bool) openssl_x509_parse($contents); } + + /** + * Uses stream_copy_to_stream instead of copy to work around https://bugs.php.net/bug.php?id=64634 + * + * @param string $source + * @param string $target + */ + private function streamCopy($source, $target) + { + $source = fopen($source, 'r'); + $target = fopen($target, 'w+'); + + stream_copy_to_stream($source, $target); + fclose($source); + fclose($target); + + unset($source, $target); + } } diff --git a/tests/Composer/Test/Downloader/ZipDownloaderTest.php b/tests/Composer/Test/Downloader/ZipDownloaderTest.php index cb5a56569..07a358fb9 100644 --- a/tests/Composer/Test/Downloader/ZipDownloaderTest.php +++ b/tests/Composer/Test/Downloader/ZipDownloaderTest.php @@ -64,6 +64,10 @@ class ZipDownloaderTest extends \PHPUnit_Framework_TestCase ->with('cafile') ->will($this->returnValue(null)); $config->expects($this->at(2)) + ->method('get') + ->with('capath') + ->will($this->returnValue(null)); + $config->expects($this->at(3)) ->method('get') ->with('vendor-dir') ->will($this->returnValue($this->testDir));