diff --git a/Makefile.e2e b/Makefile.e2e index 652385ce0..717aeb066 100644 --- a/Makefile.e2e +++ b/Makefile.e2e @@ -434,7 +434,6 @@ e2e_reproducible_build: $(SCOPED_BOX_BIN) $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/vend --config=box.json.dist \ --no-parallel mv -fv $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/index.phar $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) - php $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/resign.php $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) \ 1>$(E2E_REPRODUCIBLE_BUILD_BIN_ACTUAL_STDOUT) \ 2>$(E2E_REPRODUCIBLE_BUILD_BIN_ACTUAL_STDERR) @@ -451,7 +450,6 @@ e2e_reproducible_build: $(SCOPED_BOX_BIN) $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/vend --config=box.json.dist \ --no-parallel mv -fv $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/index.phar $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) - php $(E2E_REPRODUCIBLE_BUILD_BIN_DIR)/resign.php $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) mv $(E2E_REPRODUCIBLE_BUILD_BIN_PHAR) $(E2E_REPRODUCIBLE_BUILD_BIN_OUTPUT_DIR)/index-2.phar diff --git a/README.md b/README.md index e0bbbbfa0..52153de0b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ For the full documentation see https://box-project.github.io/box. 1. [Shebang (`shebang`)](doc/configuration.md#shebang-shebang) 1. [Banner (`banner`)](doc/configuration.md#banner-banner) 1. [Banner file (`banner-file`)](doc/configuration.md#banner-file-banner-file) + 1. [Forcing the timestamp (`timestamp`)](doc/configuration.md#forcing-the-timestamp-timestamp) 1. [Dumping the Composer autoloader (`dump-autoload`)](doc/configuration.md#dumping-the-composer-autoloader-dump-autoload) 1. [Compactors (`compactors`)](doc/configuration.md#compactors-compactors) 1. [Annotations (`annotations`)](doc/configuration.md#annotations-annotations) diff --git a/composer.json b/composer.json index bb7adcc41..2869ae685 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "psr/log": "^3.0", "sebastian/diff": "^4.0", "seld/jsonlint": "^1.9", + "seld/phar-utils": "^1.2", "symfony/console": "^6.1.7", "symfony/filesystem": "^6.1.5", "symfony/finder": "^6.1.3", diff --git a/composer.lock b/composer.lock index 51444e388..bf2cc96d1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "50f25cdeee25a0f13d7e0d131c673111", + "content-hash": "2ca942294db7c323405284645f0aca57", "packages": [ { "name": "amphp/amp", @@ -1842,6 +1842,54 @@ ], "time": "2023-05-11T13:16:46+00:00" }, + { + "name": "seld/phar-utils", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", + "keywords": [ + "phar" + ], + "support": { + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + }, + "time": "2022-08-31T10:31:18+00:00" + }, { "name": "symfony/console", "version": "v6.3.4", diff --git a/doc/configuration.md b/doc/configuration.md index fe8048dc1..e6d0943d8 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,6 +20,7 @@ 1. [Shebang (`shebang`)][shebang] 1. [Banner (`banner`)][banner] 1. [Banner file (`banner-file`)][banner-file] +1. [Forcing the timestamp (`timestamp`)][timestamp] 1. [Dumping the Composer autoloader (`dump-autoload`)][dump-autoload] 1. [Compactors (`compactors`)][compactors] 1. [Annotations (`annotations`)][annotations-compactor] @@ -92,7 +93,8 @@ to `null`, then its default value will be picked and is strictly equivalent to n "replacement-sigil": "?", "replacements": "?", "shebang": "?", - "stub": "?" + "stub": "?", + "timestamp": "?" } ``` @@ -592,6 +594,16 @@ Like banner, the comment must not already be enclosed in a comment block. If this parameter is set to a different value than `null`, then the value of [`banner`][banner] will be discarded. +## Forcing the timestamp (`timestamp`) + +The `timestamp` (`string`|`null`, default `null`) setting will result in Box forcing the timestamp of the PHAR. By +default, the timestamp of the PHAR is the one at which the PHAR was built. It may be useful to fix it for +[reproducible builds][reproducible-builds]. + +!!! Warning + Forcing the timestamp cannot be done when using an [OpenSSL signature][algorithm]. + + ## Dumping the Composer autoloader (`dump-autoload`) The `dump-autoload` (`boolean`|`null`, default `true`) setting will result in Box dump the Composer autoload with the @@ -1048,9 +1060,11 @@ The short commit hash will only be used if no tag is available. [placeholders]: #replaceable-placeholders [replacement-sigil]: #replacement-sigil-replacement-sigil [replacements]: #replacements-replacements +[reproducible-builds]: reproducible-builds.md#reproducible-builds [requirement-checker]: requirement-checker.md#requirements-checker [security]: #security [shebang]: #shebang-shebang +[timestamp]: #forcing-the-timestamp-timestamp [the signing best practices]: ./phar-signing.md#phar-signing-best-practices [stub-stub]: #stub-stub [stub]: #stub diff --git a/doc/reproducible-builds.md b/doc/reproducible-builds.md index 75319d2d2..58caacbbf 100644 --- a/doc/reproducible-builds.md +++ b/doc/reproducible-builds.md @@ -103,39 +103,12 @@ By default, Box generates a [banner][banner]. This banners includes the Box vers different Box versions will result in a different PHAR signature. -### PHAR +### Timestamp The files unix timestamp are part of the PHAR signature, hence if they have a different timestamp (which they do as when you add a PHAR to a file, it is changed to the time at when you added it). -To fix this, you can leverage [Seldaek PHAR-Utils][phar-utils] with the following script: - -```php -// resign.php -updateTimestamps(new DateTimeImmutable('2017-10-11 08:58:00')); -$util->save($file, Phar::SHA512); -``` - -Then once your PHAR is built: - -```shell -php resign.php app.phar -``` - -This is obviously not ideal and should be fixed by Box at some point (see [#1074](https://github.com/box-project/box/issues/1074)). +To fix this, you can leverage configure the [timestamp]. ## Usages @@ -170,3 +143,4 @@ but it is enough to know if the PHARs are identical or not. [php-scoper-compactor]: ./configuration.md#compactors-compactors [php-scoper-prefix-doc]: https://github.com/humbug/php-scoper/blob/main/docs/configuration.md#prefix [requirement-checker]: ./requirement-checker.md +[timestamp]: ./configuration.md#forcing-the-timestamp-timestamp diff --git a/fixtures/build/dir020-reproducible-builds/box.json.dist b/fixtures/build/dir020-reproducible-builds/box.json.dist index 2a94291ac..79644128d 100644 --- a/fixtures/build/dir020-reproducible-builds/box.json.dist +++ b/fixtures/build/dir020-reproducible-builds/box.json.dist @@ -4,5 +4,6 @@ "alias": "e20-app", "compactors": [ "KevinGH\\Box\\Compactor\\PhpScoper" - ] + ], + "timestamp": "2020-10-20 10:01:11" } diff --git a/fixtures/build/dir020-reproducible-builds/resign.php b/fixtures/build/dir020-reproducible-builds/resign.php deleted file mode 100644 index 207ee72fc..000000000 --- a/fixtures/build/dir020-reproducible-builds/resign.php +++ /dev/null @@ -1,27 +0,0 @@ - - * Théo Fidry - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -require_once __DIR__.'/vendor/autoload.php'; - -use Seld\PharUtils\Timestamps; - -$file = getcwd().'/'.($argv[1] ?? ''); -if (!is_file($file)) { - echo "File does not exist.\n"; - exit(1); -} - -$util = new Timestamps($file); -$util->updateTimestamps(new DateTimeImmutable('2017-10-11 08:58:00')); -$util->save($file, Phar::SHA512); diff --git a/res/schema.json b/res/schema.json index bf4fd0630..bc273453d 100644 --- a/res/schema.json +++ b/res/schema.json @@ -209,6 +209,10 @@ "stub": { "description": "The relative file path to the stub file, or the flag to use the default stub.", "type": ["boolean", "string", "null"] + }, + "timestamp": { + "description": "The time at which the PHAR timestamp will be set.", + "type": ["string", "null"] } } } diff --git a/src/Box.php b/src/Box.php index 5849c078d..04581a4d0 100644 --- a/src/Box.php +++ b/src/Box.php @@ -17,6 +17,7 @@ use Amp\MultiReasonException; use BadMethodCallException; use Countable; +use DateTimeImmutable; use Fidry\FileSystem\FS; use Humbug\PhpScoper\Symbol\SymbolsRegistry; use KevinGH\Box\Compactor\Compactors; @@ -29,6 +30,7 @@ use Phar; use RecursiveDirectoryIterator; use RuntimeException; +use Seld\PharUtils\Timestamps; use SplFileInfo; use Webmozart\Assert\Assert; use function Amp\ParallelFunctions\parallelMap; @@ -66,7 +68,7 @@ final class Box implements Countable private array $bufferedFiles = []; private function __construct( - private readonly Phar $phar, + private Phar $phar, private readonly string $pharFilePath, ) { $this->compactors = new Compactors(); @@ -367,9 +369,28 @@ public function extractTo(string $directory, bool $overwrite = false): void $this->phar->extractTo($directory, overwrite: $overwrite); } - public function sign(SigningAlgorithm $signingAlgorithm): void - { - $this->phar->setSignatureAlgorithm($signingAlgorithm->value); + public function sign( + SigningAlgorithm $signingAlgorithm, + ?DateTimeImmutable $timestamp = null, + ): void { + if (null === $timestamp) { + $this->phar->setSignatureAlgorithm($signingAlgorithm->value); + + return; + } + + $phar = $this->phar; + $phar->__destruct(); + unset($this->phar); + + $util = new Timestamps($this->pharFilePath); + $util->updateTimestamps($timestamp); + $util->save( + $this->pharFilePath, + $signingAlgorithm->value, + ); + + $this->phar = new Phar($this->pharFilePath); } /** diff --git a/src/Configuration/Configuration.php b/src/Configuration/Configuration.php index 49ac3c140..d66a22361 100644 --- a/src/Configuration/Configuration.php +++ b/src/Configuration/Configuration.php @@ -211,6 +211,7 @@ final class Configuration private const REPLACEMENTS_KEY = 'replacements'; private const SHEBANG_KEY = 'shebang'; private const STUB_KEY = 'stub'; + private const TIMESTAMP = 'timestamp'; private ?string $mainScriptPath; private ?string $mainScriptContents; @@ -344,6 +345,8 @@ public static function create(?string $file, stdClass $raw): self $replacements = self::retrieveReplacements($raw, $file, $basePath, $logger); + $timestamp = self::retrieveTimestamp($raw, $signingAlgorithm, $logger); + return new self( $file, $alias, @@ -376,6 +379,7 @@ public static function create(?string $file, stdClass $raw): self $stubPath, $isInterceptsFileFunctions, $isStubGenerated, + $timestamp, $checkRequirements, $logger->getWarnings(), $logger->getRecommendations(), @@ -383,38 +387,39 @@ public static function create(?string $file, stdClass $raw): self } /** - * @param string $basePath Utility to private the base path used and be able to retrieve a - * path relative to it (the base path) - * @param array $composerJson The first element is the path to the `composer.json` file as a - * string and the second element its decoded contents as an - * associative array. - * @param array $composerLock The first element is the path to the `composer.lock` file as a - * string and the second element its decoded contents as an - * associative array. - * @param SplFileInfo[] $files List of files - * @param SplFileInfo[] $binaryFiles List of binary files - * @param bool $dumpAutoload Whether the Composer autoloader should be dumped - * @param bool $excludeComposerFiles Whether the Composer files composer.json, composer.lock and - * installed.json should be removed from the PHAR - * @param CompressionAlgorithm $compressionAlgorithm Compression algorithm constant value. See the \Phar class constants - * @param null|int $fileMode File mode in octal form - * @param string $mainScriptPath The main script file path - * @param string $mainScriptContents The processed content of the main script file - * @param MapFile $fileMapper Utility to map the files from outside and inside the PHAR - * @param mixed $metadata The PHAR Metadata - * @param bool $promptForPrivateKey If the user should be prompted for the private key passphrase - * @param array $processedReplacements The processed list of replacement placeholders and their values - * @param null|non-empty-string $shebang The shebang line - * @param SigningAlgorithm $signingAlgorithm The PHAR siging algorithm. See \Phar constants - * @param null|string $stubBannerContents The stub banner comment - * @param null|string $stubBannerPath The path to the stub banner comment file - * @param null|string $stubPath The PHAR stub file path - * @param bool $isInterceptFileFuncs Whether Phar::interceptFileFuncs() should be used - * @param bool $isStubGenerated Whether if the PHAR stub should be generated - * @param bool $checkRequirements Whether the PHAR will check the application requirements before - * running - * @param string[] $warnings - * @param string[] $recommendations + * @param string $basePath Utility to private the base path used and be able to retrieve a + * path relative to it (the base path) + * @param array $composerJson The first element is the path to the `composer.json` file as a + * string and the second element its decoded contents as an + * associative array. + * @param array $composerLock The first element is the path to the `composer.lock` file as a + * string and the second element its decoded contents as an + * associative array. + * @param SplFileInfo[] $files List of files + * @param SplFileInfo[] $binaryFiles List of binary files + * @param bool $dumpAutoload Whether the Composer autoloader should be dumped + * @param bool $excludeComposerFiles Whether the Composer files composer.json, composer.lock and + * installed.json should be removed from the PHAR + * @param CompressionAlgorithm $compressionAlgorithm Compression algorithm constant value. See the \Phar class constants + * @param null|int $fileMode File mode in octal form + * @param string $mainScriptPath The main script file path + * @param string $mainScriptContents The processed content of the main script file + * @param MapFile $fileMapper Utility to map the files from outside and inside the PHAR + * @param mixed $metadata The PHAR Metadata + * @param bool $promptForPrivateKey If the user should be prompted for the private key passphrase + * @param array $processedReplacements The processed list of replacement placeholders and their values + * @param null|non-empty-string $shebang The shebang line + * @param SigningAlgorithm $signingAlgorithm The PHAR siging algorithm. See \Phar constants + * @param null|string $stubBannerContents The stub banner comment + * @param null|string $stubBannerPath The path to the stub banner comment file + * @param null|string $stubPath The PHAR stub file path + * @param bool $isInterceptFileFuncs Whether Phar::interceptFileFuncs() should be used + * @param bool $isStubGenerated Whether if the PHAR stub should be generated + * @param null|DateTimeImmutable $timestamp Timestamp at which the PHAR will be set to. + * @param bool $checkRequirements Whether the PHAR will check the application requirements before + * running + * @param string[] $warnings + * @param string[] $recommendations */ private function __construct( private ?string $file, @@ -448,6 +453,7 @@ private function __construct( private ?string $stubPath, private bool $isInterceptFileFuncs, private bool $isStubGenerated, + private ?DateTimeImmutable $timestamp, private bool $checkRequirements, private array $warnings, private array $recommendations, @@ -679,6 +685,11 @@ public function isStubGenerated(): bool return $this->isStubGenerated; } + public function getTimestamp(): ?DateTimeImmutable + { + return $this->timestamp; + } + /** * @return string[] */ @@ -2064,6 +2075,37 @@ private static function retrieveReplacements( return $replacements; } + private static function retrieveTimestamp( + stdClass $raw, + SigningAlgorithm $signingAlgorithm, + ConfigurationLogger $logger, + ): ?DateTimeImmutable { + self::checkIfDefaultValue($logger, $raw, self::TIMESTAMP); + + $timestamp = $raw->{self::TIMESTAMP} ?? null; + + if (null === $timestamp) { + return null; + } + + if (SigningAlgorithm::OPENSSL === $signingAlgorithm) { + $logger->addWarning( + sprintf( + 'The "%s" setting has been set but has been ignored since an OpenSSL signature has been configured (setting "%s").', + self::TIMESTAMP, + self::ALGORITHM_KEY, + ), + ); + + return null; + } + + return new DateTimeImmutable( + $timestamp, + new DateTimeZone('UTC'), + ); + } + private static function retrievePrettyGitPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string { return self::retrievePlaceholder($raw, $logger, self::GIT_KEY); diff --git a/src/Console/Command/Compile.php b/src/Console/Command/Compile.php index 9cc350a01..6fdf18e70 100644 --- a/src/Console/Command/Compile.php +++ b/src/Console/Command/Compile.php @@ -15,6 +15,8 @@ namespace KevinGH\Box\Console\Command; use Amp\MultiReasonException; +use DateTimeImmutable; +use DateTimeInterface; use Fidry\Console\Command\Command; use Fidry\Console\Command\CommandAware; use Fidry\Console\Command\CommandAwareness; @@ -37,6 +39,7 @@ use KevinGH\Box\Console\MessageRenderer; use KevinGH\Box\MapFile; use KevinGH\Box\Phar\CompressionAlgorithm; +use KevinGH\Box\Phar\SigningAlgorithm; use KevinGH\Box\RequirementChecker\DecodedComposerJson; use KevinGH\Box\RequirementChecker\DecodedComposerLock; use KevinGH\Box\RequirementChecker\RequirementsDumper; @@ -243,7 +246,8 @@ private function createPhar( IO $io, bool $debug, ): Box { - $box = Box::create($config->getTmpOutputPath()); + $tmpOutputPath = $config->getTmpOutputPath(); + $box = Box::create($tmpOutputPath); $composerOrchestrator = new ComposerOrchestrator( ComposerProcessFactory::create( $config->getComposerBin(), @@ -288,10 +292,10 @@ private function createPhar( $logger, ); - self::signPhar($config, $box, $config->getTmpOutputPath(), $io, $logger); + self::signPhar($config, $box, $tmpOutputPath, $io, $logger); - if ($config->getTmpOutputPath() !== $config->getOutputPath()) { - FS::rename($config->getTmpOutputPath(), $config->getOutputPath()); + if ($tmpOutputPath !== $config->getOutputPath()) { + FS::rename($tmpOutputPath, $config->getOutputPath()); } return $box; @@ -753,19 +757,57 @@ private static function signPhar( $key = $config->getPrivateKeyPath(); if (null === $key) { - $box->sign($config->getSigningAlgorithm()); + self::signPharWithoutPrivateKey( + $box, + $config->getSigningAlgorithm(), + $config->getTimestamp(), + $logger, + ); + } else { + self::signPharWithPrivateKey( + $box, + $key, + $config->getPrivateKeyPassphrase(), + $config->promptForPrivateKey(), + $io, + $logger, + ); + } + } - return; + private static function signPharWithoutPrivateKey( + Box $box, + SigningAlgorithm $signingAlgorithm, + ?DateTimeImmutable $timestamp, + CompilerLogger $logger, + ): void { + if (null !== $timestamp) { + $logger->log( + CompilerLogger::QUESTION_MARK_PREFIX, + sprintf( + 'Correcting the timestamp to "%s".', + $timestamp->format(DateTimeInterface::ATOM), + ), + ); } + $box->sign($signingAlgorithm, $timestamp); + } + + private static function signPharWithPrivateKey( + Box $box, + string $key, + ?string $passphrase, + bool $prompt, + IO $io, + CompilerLogger $logger, + ): void { $logger->log( CompilerLogger::QUESTION_MARK_PREFIX, 'Signing using a private key', ); - $passphrase = $config->getPrivateKeyPassphrase(); - - if ($config->promptForPrivateKey()) { + if ($prompt) { if (false === $io->isInteractive()) { throw new RuntimeException( sprintf( diff --git a/tests/Configuration/ConfigurationTest.php b/tests/Configuration/ConfigurationTest.php index 8e8173add..1ddeeb2dc 100644 --- a/tests/Configuration/ConfigurationTest.php +++ b/tests/Configuration/ConfigurationTest.php @@ -15,6 +15,7 @@ namespace KevinGH\Box\Configuration; use DateTimeImmutable; +use DateTimeInterface; use Fidry\FileSystem\FS; use InvalidArgumentException; use KevinGH\Box\Compactor\DummyCompactor; @@ -2896,6 +2897,58 @@ public function test_a_warning_is_given_if_dev_files_are_explicitly_excluded_but ); } + public function test_the_timestamp_can_be_configured(): void + { + $this->setConfig([ + 'timestamp' => '2020-10-20T10:01:11+00:00', + ]); + + self::assertSame( + '2020-10-20T10:01:11+00:00', + $this->config->getTimestamp()?->format(DateTimeInterface::ATOM), + ); + + self::assertSame([], $this->config->getRecommendations()); + self::assertSame([], $this->config->getWarnings()); + } + + public function test_a_recommendation_is_given_if_the_timestamp_configured_is_the_default_value(): void + { + $this->setConfig([ + 'timestamp' => null, + ]); + + self::assertNull($this->config->getTimestamp()); + + self::assertSame( + ['The "timestamp" setting can be omitted since is set to its default value'], + $this->config->getRecommendations(), + ); + self::assertSame([], $this->config->getWarnings()); + } + + public function test_a_warning_is_given_if_the_timestamp_is_configured_with_an_openssl_signature(): void + { + FS::touch('private-key'); + + $this->setConfig([ + 'timestamp' => '2020-10-20T10:01:11+00:00', + 'algorithm' => 'OPENSSL', + 'key' => 'private-key', + ]); + + self::assertNull($this->config->getTimestamp()); + + self::assertSame([], $this->config->getRecommendations()); + self::assertSame( + [ + 'Using an OpenSSL signature is deprecated and will be removed in 5.0.0. Please check https://github.com/box-project/box/blob/main/doc/phar-signing.md for alternatives.', + 'The "timestamp" setting has been set but has been ignored since an OpenSSL signature has been configured (setting "algorithm").', + ], + $this->config->getWarnings(), + ); + } + public function test_it_can_be_created_with_only_default_values(): void { $this->setConfig( @@ -2951,6 +3004,7 @@ public function test_it_can_be_created_with_only_default_values(): void self::assertFalse($this->config->isInterceptFileFuncs()); self::assertFalse($this->config->promptForPrivateKey()); self::assertTrue($this->config->isStubGenerated()); + self::assertNull($this->config->getTimestamp()); } public function test_it_can_be_exported(): void