From 2b91430a956fb0dc0051bf6f7123b75b6f09f61b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 14 Mar 2025 09:21:31 +0000 Subject: [PATCH 01/15] Initial structure of self-update command with skeleton of verification --- bin/pie | 2 + src/Command/SelfUpdateCommand.php | 101 +++++++++++++++++ src/Container.php | 6 ++ src/SelfManage/Update/FetchPieRelease.php | 20 ++++ .../Update/FetchPieReleaseFromGitHub.php | 102 ++++++++++++++++++ src/SelfManage/Update/ReleaseMetadata.php | 19 ++++ .../Verify/FailedToVerifyRelease.php | 23 ++++ src/SelfManage/Verify/VerifyPiePhar.php | 15 +++ .../VerifyPieReleaseUsingAttestation.php | 72 +++++++++++++ 9 files changed, 360 insertions(+) create mode 100644 src/Command/SelfUpdateCommand.php create mode 100644 src/SelfManage/Update/FetchPieRelease.php create mode 100644 src/SelfManage/Update/FetchPieReleaseFromGitHub.php create mode 100644 src/SelfManage/Update/ReleaseMetadata.php create mode 100644 src/SelfManage/Verify/FailedToVerifyRelease.php create mode 100644 src/SelfManage/Verify/VerifyPiePhar.php create mode 100644 src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php diff --git a/bin/pie b/bin/pie index 6296c5f7..d6d71fb9 100755 --- a/bin/pie +++ b/bin/pie @@ -12,6 +12,7 @@ use Php\Pie\Command\InstallCommand; use Php\Pie\Command\RepositoryAddCommand; use Php\Pie\Command\RepositoryListCommand; use Php\Pie\Command\RepositoryRemoveCommand; +use Php\Pie\Command\SelfUpdateCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; use Php\Pie\Util\PieVersion; @@ -42,6 +43,7 @@ $application->setCommandLoader(new ContainerCommandLoader( 'repository:add' => RepositoryAddCommand::class, 'repository:remove' => RepositoryRemoveCommand::class, 'uninstall' => UninstallCommand::class, + 'self-update' => SelfUpdateCommand::class, ] )); diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php new file mode 100644 index 00000000..8f727761 --- /dev/null +++ b/src/Command/SelfUpdateCommand.php @@ -0,0 +1,101 @@ +container, + PieComposerRequest::noOperation( + $output, + $targetPlatform, + ), + ); + + $httpDownloader = new HttpDownloader($this->io, $composer->getConfig()); + $authHelper = new AuthHelper($this->io, $composer->getConfig()); + $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); + $verifyPiePhar = new VerifyPieReleaseUsingAttestation($this->githubApiBaseUrl, $httpDownloader, $authHelper); + + $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata($httpDownloader, $authHelper); + //$pieVersion = PieVersion::get(); + $pieVersion = '0.7.0'; // @todo for testing only + + $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); + + if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) { + $output->writeln('You already have the latest version 😍'); + + return Command::SUCCESS; + } + + $output->writeln(sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag)); + + $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease, $httpDownloader, $authHelper); + + $output->writeln(sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum)); + + try { + $verifyPiePhar->verify($latestRelease, $pharFilename, $httpDownloader, $authHelper); + } catch (FailedToVerifyRelease $failedToVerifyRelease) { + $output->writeln(sprintf( + 'Failed to verify the pie.phar release %s: %s', + $latestRelease->tag, + $failedToVerifyRelease->getMessage(), + )); + + $output->writeln('This means I could not verify that the PHAR we tried to update to was authentic, so I am aborting the self-update.'); + unlink($pharFilename->filePath); + + return Command::FAILURE; + } + + // @todo move $pharFilename into place + $output->writeln('done...(ish)'); + + return Command::SUCCESS; + } +} diff --git a/src/Container.php b/src/Container.php index 351db890..9d2da927 100644 --- a/src/Container.php +++ b/src/Container.php @@ -16,6 +16,7 @@ use Php\Pie\Command\RepositoryAddCommand; use Php\Pie\Command\RepositoryListCommand; use Php\Pie\Command\RepositoryRemoveCommand; +use Php\Pie\Command\SelfUpdateCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; @@ -56,6 +57,7 @@ public static function factory(): ContainerInterface $container->singleton(RepositoryAddCommand::class); $container->singleton(RepositoryRemoveCommand::class); $container->singleton(UninstallCommand::class); + $container->singleton(SelfUpdateCommand::class); $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO { return new QuieterConsoleIO( @@ -76,6 +78,10 @@ public static function factory(): ContainerInterface ->needs('$githubApiBaseUrl') ->give('https://api.github.com'); + $container->when(SelfUpdateCommand::class) + ->needs('$githubApiBaseUrl') + ->give('https://api.github.com'); + $container->singleton( Build::class, static function (ContainerInterface $container): Build { diff --git a/src/SelfManage/Update/FetchPieRelease.php b/src/SelfManage/Update/FetchPieRelease.php new file mode 100644 index 00000000..5c89e67f --- /dev/null +++ b/src/SelfManage/Update/FetchPieRelease.php @@ -0,0 +1,20 @@ +githubApiBaseUrl . self::PIE_LATEST_RELEASE_URL; + + $decodedRepsonse = $this->httpDownloader->get( + $url, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $url), + ], + ], + )->decodeJson(); + + Assert::isArray($decodedRepsonse); + Assert::keyExists($decodedRepsonse, 'tag_name'); + Assert::stringNotEmpty($decodedRepsonse['tag_name']); + Assert::keyExists($decodedRepsonse, 'assets'); + Assert::isList($decodedRepsonse['assets']); + + $assetsNamedPiePhar = array_filter( + array_map( + /** @return array{name: non-empty-string, browser_download_url: non-empty-string, ...} */ + static function (array $asset): array { + Assert::keyExists($asset, 'name'); + Assert::stringNotEmpty($asset['name']); + Assert::keyExists($asset, 'browser_download_url'); + Assert::stringNotEmpty($asset['browser_download_url']); + + return $asset; + }, + $decodedRepsonse['assets'], + ), + static function (array $asset): bool { + return $asset['name'] === self::PIE_PHAR_NAME; + }, + ); + $firstAssetNamedPiePhar = reset($assetsNamedPiePhar); + + return new ReleaseMetadata( + $decodedRepsonse['tag_name'], + $firstAssetNamedPiePhar['browser_download_url'], + ); + } + + public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile + { + $pharContent = $this->httpDownloader->get( + $releaseMetadata->downloadUrl, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $releaseMetadata->downloadUrl), + ], + ], + )->getBody(); + Assert::stringNotEmpty($pharContent); + + $tempPharFilename = tempnam(sys_get_temp_dir(), 'pie_self_update_'); + Assert::stringNotEmpty($tempPharFilename); + + if (file_put_contents($tempPharFilename, $pharContent) === false) { + throw new RuntimeException('Failed to write downloaded PHAR to ' . $tempPharFilename); + } + + return BinaryFile::fromFileWithSha256Checksum($tempPharFilename); + } +} diff --git a/src/SelfManage/Update/ReleaseMetadata.php b/src/SelfManage/Update/ReleaseMetadata.php new file mode 100644 index 00000000..912ded2c --- /dev/null +++ b/src/SelfManage/Update/ReleaseMetadata.php @@ -0,0 +1,19 @@ +tag, + $file->checksum, + )); + } +} diff --git a/src/SelfManage/Verify/VerifyPiePhar.php b/src/SelfManage/Verify/VerifyPiePhar.php new file mode 100644 index 00000000..8a7387fb --- /dev/null +++ b/src/SelfManage/Verify/VerifyPiePhar.php @@ -0,0 +1,15 @@ +verifyUsingGhCli(); + $this->verifyUsingOpenSSL($releaseMetadata, $pharFilename); + } + + private function verifyUsingGhCli(): void + { + // @todo verify using `gh attestation verify` etc + } + + private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): void + { + // @todo require openssl + + $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename); + + /** + * @link https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256 + * + * @todo verify the signature against the certificate + */ + } + + private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array + { + $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; + + try { + return $this->httpDownloader->get( + $attestationUrl, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $attestationUrl), + ], + ], + )->decodeJson(); + } catch (TransportException $transportException) { + if ($transportException->getStatusCode() === 404) { + throw FailedToVerifyRelease::fromMissingAttestation($releaseMetadata, $pharFilename); + } + + throw $transportException; + } + } +} From ac3e7954308acea137b42988f56d1ffb6f697537 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 18 Mar 2025 21:56:30 +0000 Subject: [PATCH 02/15] Added fallback approach for verifying downloaded PIE phar provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Düsterhus --- src/Command/SelfUpdateCommand.php | 10 +- src/File/BinaryFile.php | 3 +- .../Verify/FailedToVerifyRelease.php | 19 ++++ src/SelfManage/Verify/VerifyPiePhar.php | 3 +- .../VerifyPieReleaseUsingAttestation.php | 97 ++++++++++++++++--- 5 files changed, 115 insertions(+), 17 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 8f727761..32f26918 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -55,6 +55,8 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); + // @todo check we're running in a PHAR + $httpDownloader = new HttpDownloader($this->io, $composer->getConfig()); $authHelper = new AuthHelper($this->io, $composer->getConfig()); $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); @@ -74,15 +76,15 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag)); - $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease, $httpDownloader, $authHelper); + $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease); $output->writeln(sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum)); try { - $verifyPiePhar->verify($latestRelease, $pharFilename, $httpDownloader, $authHelper); + $verifyPiePhar->verify($latestRelease, $pharFilename, $output); } catch (FailedToVerifyRelease $failedToVerifyRelease) { $output->writeln(sprintf( - 'Failed to verify the pie.phar release %s: %s', + '❌ Failed to verify the pie.phar release %s: %s', $latestRelease->tag, $failedToVerifyRelease->getMessage(), )); @@ -94,7 +96,7 @@ public function execute(InputInterface $input, OutputInterface $output): int } // @todo move $pharFilename into place - $output->writeln('done...(ish)'); + $output->writeln(sprintf('TODO: Move %s to %s', $pharFilename->filePath, $_SERVER['PHP_SELF'])); return Command::SUCCESS; } diff --git a/src/File/BinaryFile.php b/src/File/BinaryFile.php index db3ae942..873e5cc6 100644 --- a/src/File/BinaryFile.php +++ b/src/File/BinaryFile.php @@ -7,6 +7,7 @@ use Php\Pie\Util; use function file_exists; +use function hash_equals; use function hash_file; /** @@ -53,7 +54,7 @@ public function verifyAgainstOther(self $other): void throw BinaryFileFailedVerification::fromFilenameMismatch($this, $other); } - if ($other->checksum !== $this->checksum) { + if (! hash_equals($this->checksum, $other->checksum)) { throw BinaryFileFailedVerification::fromChecksumMismatch($this, $other); } } diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 407d6855..54fde90d 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -12,6 +12,11 @@ class FailedToVerifyRelease extends RuntimeException { + public static function fromInvalidSubjectDefinition(): self + { + return new self('Invalid subject definition in attestation payload'); + } + public static function fromMissingAttestation(ReleaseMetadata $releaseMetadata, BinaryFile $file): self { return new self(sprintf( @@ -20,4 +25,18 @@ public static function fromMissingAttestation(ReleaseMetadata $releaseMetadata, $file->checksum, )); } + + public static function fromSignatureVerificationFailed(int $attestationIndex, ReleaseMetadata $releaseMetadata): self + { + return new self(sprintf( + 'Failed to verify DSSE Envelope payload signature for attestation %d for %s', + $attestationIndex, + $releaseMetadata->tag, + )); + } + + public static function fromNoOpenssl(): self + { + return new self('Unable to verify without `gh` CLI tool, or openssl extension.'); + } } diff --git a/src/SelfManage/Verify/VerifyPiePhar.php b/src/SelfManage/Verify/VerifyPiePhar.php index 8a7387fb..1d3ac05a 100644 --- a/src/SelfManage/Verify/VerifyPiePhar.php +++ b/src/SelfManage/Verify/VerifyPiePhar.php @@ -6,10 +6,11 @@ use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; +use Symfony\Component\Console\Output\OutputInterface; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ interface VerifyPiePhar { /** @throws FailedToVerifyRelease */ - public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): void; + public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void; } diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index fea09d8e..496ad6e7 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -7,8 +7,25 @@ use Composer\Downloader\TransportException; use Composer\Util\AuthHelper; use Composer\Util\HttpDownloader; +use OpenSSLAsymmetricKey; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; +use Symfony\Component\Console\Output\OutputInterface; +use Webmozart\Assert\Assert; + +use function array_key_exists; +use function base64_decode; +use function count; +use function extension_loaded; +use function is_array; +use function json_decode; +use function openssl_pkey_get_public; +use function openssl_verify; +use function sprintf; +use function strlen; +use function wordwrap; + +use const OPENSSL_ALGO_SHA256; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class VerifyPieReleaseUsingAttestation implements VerifyPiePhar @@ -20,12 +37,11 @@ public function __construct( ) { } - public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): void + public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void { - throw new FailedToVerifyRelease('todo'); - // @todo prefer this $this->verifyUsingGhCli(); - $this->verifyUsingOpenSSL($releaseMetadata, $pharFilename); + + $this->verifyUsingOpenSSL($releaseMetadata, $pharFilename, $output); } private function verifyUsingGhCli(): void @@ -33,19 +49,78 @@ private function verifyUsingGhCli(): void // @todo verify using `gh attestation verify` etc } - private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): void + private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void { - // @todo require openssl + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); + } + + $output->writeln( + 'Falling back to basic verification. To use full verification, install the `gh` CLI tool.', + OutputInterface::VERBOSITY_VERBOSE, + ); $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename); - /** - * @link https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256 - * - * @todo verify the signature against the certificate - */ + foreach ($attestations['attestations'] as $attestationIndex => $attestation) { + /** + * Useful references. Whilst we don't do the full verification that + * `gh attestation verify` would (since we don't want to re-invent + * the wheel), we can do some basic check of the DSSE Envelope. + * We'll check the payload digest matches our expectation, and + * verify the signature with the certificate. + * + * - https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256 + * - https://docs.sigstore.dev/logging/verify-release/ + * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol + */ + $payload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']); + $signature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); + $decodedPayload = json_decode($payload, true); + + if ( + ! array_key_exists('subject', $decodedPayload) + || ! is_array($decodedPayload['subject']) + || count($decodedPayload['subject']) !== 1 + || ! array_key_exists(0, $decodedPayload['subject']) + || ! array_key_exists('name', $decodedPayload['subject'][0]) + || $decodedPayload['subject'][0]['name'] !== 'pie.phar' + || ! array_key_exists('digest', $decodedPayload['subject'][0]) + || ! is_array($decodedPayload['subject'][0]['digest']) + || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest']) + ) { + throw FailedToVerifyRelease::fromInvalidSubjectDefinition(); + } + + $pharFilename->verifyAgainstOther(new BinaryFile( + $pharFilename->filePath, + $decodedPayload['subject'][0]['digest']['sha256'], + )); + + $publicKey = openssl_pkey_get_public( + "-----BEGIN CERTIFICATE-----\n" + . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n" + . "-----END CERTIFICATE-----\n", + ); + Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); + + $preAuthenticationEncoding = sprintf( + 'DSSEv1 %d %s %d %s', + strlen($attestation['bundle']['dsseEnvelope']['payloadType']), + $attestation['bundle']['dsseEnvelope']['payloadType'], + strlen($payload), + $payload, + ); + + if (openssl_verify($preAuthenticationEncoding, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); + } + } + + $output->writeln('✅ Verified the new PIE (using fallback verification)'); } + /** @return array */ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array { $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; From 9bf117438aad6647488ccdef8bd5fd1a880b032a Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 19 Mar 2025 08:27:51 +0000 Subject: [PATCH 03/15] Ensure we are running in PHAR mode before attempting self-update --- src/Command/SelfUpdateCommand.php | 11 +++-- src/Util/PieVersion.php | 70 ++++++++++++++++--------------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 32f26918..91ddef34 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -13,6 +13,7 @@ use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; +use Php\Pie\Util\PieVersion; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -45,6 +46,12 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { + if (! PieVersion::isPharBuild()) { + $output->writeln('Aborting! You are not running a PHAR, cannot self-update.'); + + return 1; + } + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); $composer = PieComposerFactory::createPieComposer( @@ -55,15 +62,13 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - // @todo check we're running in a PHAR - $httpDownloader = new HttpDownloader($this->io, $composer->getConfig()); $authHelper = new AuthHelper($this->io, $composer->getConfig()); $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); $verifyPiePhar = new VerifyPieReleaseUsingAttestation($this->githubApiBaseUrl, $httpDownloader, $authHelper); $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata($httpDownloader, $authHelper); - //$pieVersion = PieVersion::get(); + $pieVersion = PieVersion::get(); $pieVersion = '0.7.0'; // @todo for testing only $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); diff --git a/src/Util/PieVersion.php b/src/Util/PieVersion.php index 5c44126c..56166b9c 100644 --- a/src/Util/PieVersion.php +++ b/src/Util/PieVersion.php @@ -17,6 +17,28 @@ final class PieVersion */ private const SYMFONY_MAGIC_CONST_UNKNOWN = 'UNKNOWN'; + /** + * This value is replaced dynamically by Box with the real version when + * we build the PHAR. It is based on the Git tag and/or version + * + * It will be replaced with `2.0.0` on an exact tag match, or something + * like `2.0.0@e558e33` on a commit following a tag. + * + * When running not in a PHAR, this will not be replaced, so this + * method needs additional logic to determine the version. + * + * @link https://box-project.github.io/box/configuration/#pretty-git-tag-placeholder-git + */ + private const PIE_VERSION = '@pie_version@'; + + public static function isPharBuild(): bool + { + /** @psalm-suppress RedundantCondition */ + + // phpcs:ignore Generic.Strings.UnnecessaryStringConcat.Found + return self::PIE_VERSION !== '@pie_version' . '@'; + } + /** * A static method to try to find the version of PIE you are currently * running. If running in the PHAR built with Box, this should return a @@ -26,44 +48,24 @@ final class PieVersion */ public static function get(): string { - /** - * This value is replaced dynamically by Box with the real version when - * we build the PHAR. It is based on the Git tag and/or version - * - * It will be replaced with `2.0.0` on an exact tag match, or something - * like `2.0.0@e558e33` on a commit following a tag. - * - * When running not in a PHAR, this will not be replaced, so this - * method needs additional logic to determine the version. - * - * @link https://box-project.github.io/box/configuration/#pretty-git-tag-placeholder-git - */ - $pieVersion = '@pie_version@'; + if (self::isPharBuild()) { + return self::PIE_VERSION; + } + + if (! class_exists(InstalledVersions::class)) { + return self::SYMFONY_MAGIC_CONST_UNKNOWN; + } /** - * @psalm-suppress RedundantCondition - * @noinspection PhpConditionAlreadyCheckedInspection + * This tries to determine the version based on Composer; if we are + * the root package (i.e. you're developing on it), this will most + * likely be something like `dev-main` (branch name). */ - // phpcs:ignore Generic.Strings.UnnecessaryStringConcat.Found - if ($pieVersion === '@pie_version' . '@') { - if (! class_exists(InstalledVersions::class)) { - return self::SYMFONY_MAGIC_CONST_UNKNOWN; - } - - /** - * This tries to determine the version based on Composer; if we are - * the root package (i.e. you're developing on it), this will most - * likely be something like `dev-main` (branch name). - */ - $installedVersion = InstalledVersions::getVersion(InstalledVersions::getRootPackage()['name']); - if ($installedVersion === null) { - return self::SYMFONY_MAGIC_CONST_UNKNOWN; - } - - return $installedVersion; + $installedVersion = InstalledVersions::getVersion(InstalledVersions::getRootPackage()['name']); + if ($installedVersion === null) { + return self::SYMFONY_MAGIC_CONST_UNKNOWN; } - /** @psalm-suppress NoValue */ - return $pieVersion; + return $installedVersion; } } From a9487e95107ed78e3dd8ea8efc9cd03123c4dca2 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 19 Mar 2025 10:11:55 +0000 Subject: [PATCH 04/15] Write new pie.phar to script location --- src/Command/SelfUpdateCommand.php | 54 +++++++++++++++++++---- src/SelfManage/Update/FetchPieRelease.php | 6 +-- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 91ddef34..558d553e 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -10,6 +10,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\QuieterConsoleIO; +use Php\Pie\File\SudoFilePut; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; @@ -20,9 +21,16 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Webmozart\Assert\Assert; +use function file_get_contents; +use function getcwd; +use function preg_match; +use function realpath; use function sprintf; use function unlink; +use const DIRECTORY_SEPARATOR; + #[AsCommand( name: 'self-update', description: 'Self update PIE', @@ -67,23 +75,29 @@ public function execute(InputInterface $input, OutputInterface $output): int $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); $verifyPiePhar = new VerifyPieReleaseUsingAttestation($this->githubApiBaseUrl, $httpDownloader, $authHelper); - $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata($httpDownloader, $authHelper); - $pieVersion = PieVersion::get(); - $pieVersion = '0.7.0'; // @todo for testing only + $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); + $pieVersion = PieVersion::get(); + $pieVersion = '0.7.0'; // @todo for testing only $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) { - $output->writeln('You already have the latest version 😍'); + $output->writeln('You already have the latest version 😍'); return Command::SUCCESS; } - $output->writeln(sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag)); + $output->writeln( + sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag), + OutputInterface::VERBOSITY_VERBOSE, + ); $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease); - $output->writeln(sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum)); + $output->writeln( + sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum), + OutputInterface::VERBOSITY_VERBOSE, + ); try { $verifyPiePhar->verify($latestRelease, $pharFilename, $output); @@ -100,9 +114,33 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - // @todo move $pharFilename into place - $output->writeln(sprintf('TODO: Move %s to %s', $pharFilename->filePath, $_SERVER['PHP_SELF'])); + $phpSelf = $_SERVER['PHP_SELF'] ?? ''; + $fullPathToSelf = $this->isAbsolutePath($phpSelf) ? $phpSelf : (getcwd() . DIRECTORY_SEPARATOR . $phpSelf); + $output->writeln( + sprintf('Writing new version to %s', $fullPathToSelf), + OutputInterface::VERBOSITY_VERBOSE, + ); + SudoFilePut::contents($fullPathToSelf, file_get_contents($pharFilename->filePath)); + + $output->writeln('✅ PIE has been upgraded to ' . $latestRelease->tag . ''); return Command::SUCCESS; } + + private function isAbsolutePath(string $path): bool + { + if (realpath($path) === $path) { + return true; + } + + if ($path === '' || $path === '.') { + return false; + } + + if (preg_match('#^[a-zA-Z]:\\\\#', $path)) { + return true; + } + + return $path[0] === '/' || $path[0] === '\\'; + } } diff --git a/src/SelfManage/Update/FetchPieRelease.php b/src/SelfManage/Update/FetchPieRelease.php index 5c89e67f..ef02be0e 100644 --- a/src/SelfManage/Update/FetchPieRelease.php +++ b/src/SelfManage/Update/FetchPieRelease.php @@ -11,10 +11,6 @@ interface FetchPieRelease { public function latestReleaseMetadata(): ReleaseMetadata; - /** - * Download the given pie.phar and return the filename (should be a temp file) - * - * @return non-empty-string - */ + /** Download the given pie.phar and return the filename (should be a temp file) */ public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile; } From 002c58d7c2b4027fe45c0577fe779072aae0bfc7 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 19 Mar 2025 10:39:38 +0000 Subject: [PATCH 05/15] Improve attestation shape verification --- src/Command/SelfUpdateCommand.php | 1 - .../Verify/FailedToVerifyRelease.php | 2 +- .../VerifyPieReleaseUsingAttestation.php | 134 +++++++++++++----- 3 files changed, 100 insertions(+), 37 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 558d553e..e8e3a7f1 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -21,7 +21,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Webmozart\Assert\Assert; use function file_get_contents; use function getcwd; use function preg_match; diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 54fde90d..4204ec9b 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -14,7 +14,7 @@ class FailedToVerifyRelease extends RuntimeException { public static function fromInvalidSubjectDefinition(): self { - return new self('Invalid subject definition in attestation payload'); + return new self('Unable to extract subject digest from the dsseEnvelope in the attestation.'); } public static function fromMissingAttestation(ReleaseMetadata $releaseMetadata, BinaryFile $file): self diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index 496ad6e7..ccedbbac 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -14,10 +14,12 @@ use Webmozart\Assert\Assert; use function array_key_exists; +use function array_map; use function base64_decode; use function count; use function extension_loaded; use function is_array; +use function is_string; use function json_decode; use function openssl_pkey_get_public; use function openssl_verify; @@ -62,7 +64,7 @@ private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename); - foreach ($attestations['attestations'] as $attestationIndex => $attestation) { + foreach ($attestations as $attestationIndex => $attestation) { /** * Useful references. Whilst we don't do the full verification that * `gh attestation verify` would (since we don't want to re-invent @@ -74,45 +76,20 @@ private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile * - https://docs.sigstore.dev/logging/verify-release/ * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol */ - $payload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']); - $signature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); - $decodedPayload = json_decode($payload, true); - - if ( - ! array_key_exists('subject', $decodedPayload) - || ! is_array($decodedPayload['subject']) - || count($decodedPayload['subject']) !== 1 - || ! array_key_exists(0, $decodedPayload['subject']) - || ! array_key_exists('name', $decodedPayload['subject'][0]) - || $decodedPayload['subject'][0]['name'] !== 'pie.phar' - || ! array_key_exists('digest', $decodedPayload['subject'][0]) - || ! is_array($decodedPayload['subject'][0]['digest']) - || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest']) - ) { - throw FailedToVerifyRelease::fromInvalidSubjectDefinition(); - } - - $pharFilename->verifyAgainstOther(new BinaryFile( - $pharFilename->filePath, - $decodedPayload['subject'][0]['digest']['sha256'], - )); + $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation['dsseEnvelopePayload']); - $publicKey = openssl_pkey_get_public( - "-----BEGIN CERTIFICATE-----\n" - . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n" - . "-----END CERTIFICATE-----\n", - ); + $publicKey = openssl_pkey_get_public($attestation['certificate']); Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); $preAuthenticationEncoding = sprintf( 'DSSEv1 %d %s %d %s', - strlen($attestation['bundle']['dsseEnvelope']['payloadType']), - $attestation['bundle']['dsseEnvelope']['payloadType'], - strlen($payload), - $payload, + strlen($attestation['dsseEnvelopePayloadType']), + $attestation['dsseEnvelopePayloadType'], + strlen($attestation['dsseEnvelopePayload']), + $attestation['dsseEnvelopePayload'], ); - if (openssl_verify($preAuthenticationEncoding, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + if (openssl_verify($preAuthenticationEncoding, $attestation['dsseEnvelopeSignature'], $publicKey, OPENSSL_ALGO_SHA256) !== 1) { throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); } } @@ -120,13 +97,49 @@ private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $output->writeln('✅ Verified the new PIE (using fallback verification)'); } - /** @return array */ + private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, string $dsseEnvelopePayload): void + { + /** @var mixed $decodedPayload */ + $decodedPayload = json_decode($dsseEnvelopePayload, true); + + if ( + ! is_array($decodedPayload) + || ! array_key_exists('subject', $decodedPayload) + || ! is_array($decodedPayload['subject']) + || count($decodedPayload['subject']) !== 1 + || ! array_key_exists(0, $decodedPayload['subject']) + || ! is_array($decodedPayload['subject'][0]) + || ! array_key_exists('name', $decodedPayload['subject'][0]) + || $decodedPayload['subject'][0]['name'] !== 'pie.phar' + || ! array_key_exists('digest', $decodedPayload['subject'][0]) + || ! is_array($decodedPayload['subject'][0]['digest']) + || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest']) + || ! is_string($decodedPayload['subject'][0]['digest']['sha256']) + || $decodedPayload['subject'][0]['digest']['sha256'] === '' + ) { + throw FailedToVerifyRelease::fromInvalidSubjectDefinition(); + } + + $pharFilename->verifyAgainstOther(new BinaryFile( + $pharFilename->filePath, + $decodedPayload['subject'][0]['digest']['sha256'], + )); + } + + /** + * @return non-empty-list + */ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array { $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; try { - return $this->httpDownloader->get( + $decodedJson = $this->httpDownloader->get( $attestationUrl, [ 'retry-auth-failure' => false, @@ -136,6 +149,57 @@ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFi ], ], )->decodeJson(); + + Assert::isArray($decodedJson); + Assert::keyExists($decodedJson, 'attestations'); + Assert::isNonEmptyList($decodedJson['attestations']); + + return array_map( + static function (array $attestation): array { + Assert::keyExists($attestation, 'bundle'); + Assert::isArray($attestation['bundle']); + + Assert::keyExists($attestation['bundle'], 'verificationMaterial'); + Assert::isArray($attestation['bundle']['verificationMaterial']); + Assert::keyExists($attestation['bundle']['verificationMaterial'], 'certificate'); + Assert::isArray($attestation['bundle']['verificationMaterial']['certificate']); + Assert::keyExists($attestation['bundle']['verificationMaterial']['certificate'], 'rawBytes'); + Assert::stringNotEmpty($attestation['bundle']['verificationMaterial']['certificate']['rawBytes']); + + Assert::keyExists($attestation['bundle'], 'dsseEnvelope'); + Assert::isArray($attestation['bundle']['dsseEnvelope']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payload'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payload']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payloadType'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payloadType']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'signatures'); + Assert::isNonEmptyList($attestation['bundle']['dsseEnvelope']['signatures']); + Assert::count($attestation['bundle']['dsseEnvelope']['signatures'], 1); + Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'], 0); + Assert::isArray($attestation['bundle']['dsseEnvelope']['signatures'][0]); + Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'][0], 'sig'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); + + $decoratedCertificate = "-----BEGIN CERTIFICATE-----\n" + . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n" + . "-----END CERTIFICATE-----\n"; + Assert::stringNotEmpty($decoratedCertificate); + + $decodedPayload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']); + Assert::stringNotEmpty($decodedPayload); + + $decodedSignature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); + Assert::stringNotEmpty($decodedSignature); + + return [ + 'certificate' => $decoratedCertificate, + 'dsseEnvelopePayload' => $decodedPayload, + 'dsseEnvelopePayloadType' => $attestation['bundle']['dsseEnvelope']['payloadType'], + 'dsseEnvelopeSignature' => $decodedSignature, + ]; + }, + $decodedJson['attestations'], + ); } catch (TransportException $transportException) { if ($transportException->getStatusCode() === 404) { throw FailedToVerifyRelease::fromMissingAttestation($releaseMetadata, $pharFilename); From 19fc37efd3e3c0a05e04ad7cceddad55979ad326 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 20 Mar 2025 08:15:47 +0000 Subject: [PATCH 06/15] Extract Attestation parsing into simpler VO --- src/SelfManage/Verify/Attestation.php | 74 ++++++++++++ .../VerifyPieReleaseUsingAttestation.php | 106 ++++++------------ 2 files changed, 108 insertions(+), 72 deletions(-) create mode 100644 src/SelfManage/Verify/Attestation.php diff --git a/src/SelfManage/Verify/Attestation.php b/src/SelfManage/Verify/Attestation.php new file mode 100644 index 00000000..a040148d --- /dev/null +++ b/src/SelfManage/Verify/Attestation.php @@ -0,0 +1,74 @@ + $attestation */ + public static function fromAttestationBundleWithDsseEnvelope(array $attestation): self + { + Assert::keyExists($attestation, 'bundle'); + Assert::isArray($attestation['bundle']); + + Assert::keyExists($attestation['bundle'], 'verificationMaterial'); + Assert::isArray($attestation['bundle']['verificationMaterial']); + Assert::keyExists($attestation['bundle']['verificationMaterial'], 'certificate'); + Assert::isArray($attestation['bundle']['verificationMaterial']['certificate']); + Assert::keyExists($attestation['bundle']['verificationMaterial']['certificate'], 'rawBytes'); + Assert::stringNotEmpty($attestation['bundle']['verificationMaterial']['certificate']['rawBytes']); + + Assert::keyExists($attestation['bundle'], 'dsseEnvelope'); + Assert::isArray($attestation['bundle']['dsseEnvelope']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payload'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payload']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payloadType'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payloadType']); + Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'signatures'); + Assert::isNonEmptyList($attestation['bundle']['dsseEnvelope']['signatures']); + Assert::count($attestation['bundle']['dsseEnvelope']['signatures'], 1); + Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'], 0); + Assert::isArray($attestation['bundle']['dsseEnvelope']['signatures'][0]); + Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'][0], 'sig'); + Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); + + $decoratedCertificate = "-----BEGIN CERTIFICATE-----\n" + . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n" + . "-----END CERTIFICATE-----\n"; + Assert::stringNotEmpty($decoratedCertificate); + + $decodedPayload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']); + Assert::stringNotEmpty($decodedPayload); + + $decodedSignature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); + Assert::stringNotEmpty($decodedSignature); + + return new self( + $decoratedCertificate, + $decodedPayload, + $attestation['bundle']['dsseEnvelope']['payloadType'], + $decodedSignature, + ); + } +} diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index ccedbbac..639f0e63 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -15,7 +15,6 @@ use function array_key_exists; use function array_map; -use function base64_decode; use function count; use function extension_loaded; use function is_array; @@ -25,7 +24,6 @@ use function openssl_verify; use function sprintf; use function strlen; -use function wordwrap; use const OPENSSL_ALGO_SHA256; @@ -43,6 +41,10 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam { // @todo prefer this $this->verifyUsingGhCli(); + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); + } + $this->verifyUsingOpenSSL($releaseMetadata, $pharFilename, $output); } @@ -53,10 +55,6 @@ private function verifyUsingGhCli(): void private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void { - if (! extension_loaded('openssl')) { - throw FailedToVerifyRelease::fromNoOpenssl(); - } - $output->writeln( 'Falling back to basic verification. To use full verification, install the `gh` CLI tool.', OutputInterface::VERBOSITY_VERBOSE, @@ -76,31 +74,42 @@ private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile * - https://docs.sigstore.dev/logging/verify-release/ * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol */ - $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation['dsseEnvelopePayload']); + $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation); + $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE); - $publicKey = openssl_pkey_get_public($attestation['certificate']); - Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); + $this->verifyDsseEnvelopeSignature($releaseMetadata, $attestationIndex, $attestation); + $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE); + } - $preAuthenticationEncoding = sprintf( - 'DSSEv1 %d %s %d %s', - strlen($attestation['dsseEnvelopePayloadType']), - $attestation['dsseEnvelopePayloadType'], - strlen($attestation['dsseEnvelopePayload']), - $attestation['dsseEnvelopePayload'], - ); + $output->writeln('✅ Verified the new PIE (using fallback verification)'); + } - if (openssl_verify($preAuthenticationEncoding, $attestation['dsseEnvelopeSignature'], $publicKey, OPENSSL_ALGO_SHA256) !== 1) { - throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); - } + private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void + { + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); } - $output->writeln('✅ Verified the new PIE (using fallback verification)'); + $publicKey = openssl_pkey_get_public($attestation->certificate); + Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); + + $preAuthenticationEncoding = sprintf( + 'DSSEv1 %d %s %d %s', + strlen($attestation->dsseEnvelopePayloadType), + $attestation->dsseEnvelopePayloadType, + strlen($attestation->dsseEnvelopePayload), + $attestation->dsseEnvelopePayload, + ); + + if (openssl_verify($preAuthenticationEncoding, $attestation->dsseEnvelopeSignature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); + } } - private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, string $dsseEnvelopePayload): void + private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, Attestation $attestation): void { /** @var mixed $decodedPayload */ - $decodedPayload = json_decode($dsseEnvelopePayload, true); + $decodedPayload = json_decode($attestation->dsseEnvelopePayload, true); if ( ! is_array($decodedPayload) @@ -126,14 +135,7 @@ private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilena )); } - /** - * @return non-empty-list - */ + /** @return non-empty-list */ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array { $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; @@ -155,48 +157,8 @@ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFi Assert::isNonEmptyList($decodedJson['attestations']); return array_map( - static function (array $attestation): array { - Assert::keyExists($attestation, 'bundle'); - Assert::isArray($attestation['bundle']); - - Assert::keyExists($attestation['bundle'], 'verificationMaterial'); - Assert::isArray($attestation['bundle']['verificationMaterial']); - Assert::keyExists($attestation['bundle']['verificationMaterial'], 'certificate'); - Assert::isArray($attestation['bundle']['verificationMaterial']['certificate']); - Assert::keyExists($attestation['bundle']['verificationMaterial']['certificate'], 'rawBytes'); - Assert::stringNotEmpty($attestation['bundle']['verificationMaterial']['certificate']['rawBytes']); - - Assert::keyExists($attestation['bundle'], 'dsseEnvelope'); - Assert::isArray($attestation['bundle']['dsseEnvelope']); - Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payload'); - Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payload']); - Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payloadType'); - Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payloadType']); - Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'signatures'); - Assert::isNonEmptyList($attestation['bundle']['dsseEnvelope']['signatures']); - Assert::count($attestation['bundle']['dsseEnvelope']['signatures'], 1); - Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'], 0); - Assert::isArray($attestation['bundle']['dsseEnvelope']['signatures'][0]); - Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'][0], 'sig'); - Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); - - $decoratedCertificate = "-----BEGIN CERTIFICATE-----\n" - . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n" - . "-----END CERTIFICATE-----\n"; - Assert::stringNotEmpty($decoratedCertificate); - - $decodedPayload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']); - Assert::stringNotEmpty($decodedPayload); - - $decodedSignature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']); - Assert::stringNotEmpty($decodedSignature); - - return [ - 'certificate' => $decoratedCertificate, - 'dsseEnvelopePayload' => $decodedPayload, - 'dsseEnvelopePayloadType' => $attestation['bundle']['dsseEnvelope']['payloadType'], - 'dsseEnvelopeSignature' => $decodedSignature, - ]; + static function (array $attestation): Attestation { + return Attestation::fromAttestationBundleWithDsseEnvelope($attestation); }, $decodedJson['attestations'], ); From c3b3f834ff37e84ddb11281733e3b8e42033290f Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 20 Mar 2025 09:11:24 +0000 Subject: [PATCH 07/15] Split verification into method-specific classes --- src/Command/SelfUpdateCommand.php | 7 +- .../FallbackVerificationUsingOpenSsl.php | 157 ++++++++++++++++++ .../GithubCliAttestationVerification.php | 27 +++ .../VerifyPieReleaseUsingAttestation.php | 149 +---------------- 4 files changed, 194 insertions(+), 146 deletions(-) create mode 100644 src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php create mode 100644 src/SelfManage/Verify/GithubCliAttestationVerification.php diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index e8e3a7f1..484b8e8a 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -13,6 +13,8 @@ use Php\Pie\File\SudoFilePut; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; +use Php\Pie\SelfManage\Verify\FallbackVerificationUsingOpenSsl; +use Php\Pie\SelfManage\Verify\GithubCliAttestationVerification; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Util\PieVersion; use Psr\Container\ContainerInterface; @@ -72,7 +74,10 @@ public function execute(InputInterface $input, OutputInterface $output): int $httpDownloader = new HttpDownloader($this->io, $composer->getConfig()); $authHelper = new AuthHelper($this->io, $composer->getConfig()); $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); - $verifyPiePhar = new VerifyPieReleaseUsingAttestation($this->githubApiBaseUrl, $httpDownloader, $authHelper); + $verifyPiePhar = new VerifyPieReleaseUsingAttestation( + new GithubCliAttestationVerification($this->githubApiBaseUrl, $httpDownloader, $authHelper), + new FallbackVerificationUsingOpenSsl($this->githubApiBaseUrl, $httpDownloader, $authHelper), + ); $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); $pieVersion = PieVersion::get(); diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php new file mode 100644 index 00000000..b3c4f04c --- /dev/null +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -0,0 +1,157 @@ +writeln( + 'Falling back to basic verification. To use full verification, install the `gh` CLI tool.', + OutputInterface::VERBOSITY_VERBOSE, + ); + + $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename); + + foreach ($attestations as $attestationIndex => $attestation) { + /** + * Useful references. Whilst we don't do the full verification that + * `gh attestation verify` would (since we don't want to re-invent + * the wheel), we can do some basic check of the DSSE Envelope. + * We'll check the payload digest matches our expectation, and + * verify the signature with the certificate. + * + * - https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256 + * - https://docs.sigstore.dev/logging/verify-release/ + * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol + */ + $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation); + $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE); + + $this->verifyDsseEnvelopeSignature($releaseMetadata, $attestationIndex, $attestation); + $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE); + } + + $output->writeln('✅ Verified the new PIE (using fallback verification)'); + } + + private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void + { + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); + } + + $publicKey = openssl_pkey_get_public($attestation->certificate); + Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); + + $preAuthenticationEncoding = sprintf( + 'DSSEv1 %d %s %d %s', + strlen($attestation->dsseEnvelopePayloadType), + $attestation->dsseEnvelopePayloadType, + strlen($attestation->dsseEnvelopePayload), + $attestation->dsseEnvelopePayload, + ); + + if (openssl_verify($preAuthenticationEncoding, $attestation->dsseEnvelopeSignature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); + } + } + + private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, Attestation $attestation): void + { + /** @var mixed $decodedPayload */ + $decodedPayload = json_decode($attestation->dsseEnvelopePayload, true); + + if ( + ! is_array($decodedPayload) + || ! array_key_exists('subject', $decodedPayload) + || ! is_array($decodedPayload['subject']) + || count($decodedPayload['subject']) !== 1 + || ! array_key_exists(0, $decodedPayload['subject']) + || ! is_array($decodedPayload['subject'][0]) + || ! array_key_exists('name', $decodedPayload['subject'][0]) + || $decodedPayload['subject'][0]['name'] !== 'pie.phar' + || ! array_key_exists('digest', $decodedPayload['subject'][0]) + || ! is_array($decodedPayload['subject'][0]['digest']) + || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest']) + || ! is_string($decodedPayload['subject'][0]['digest']['sha256']) + || $decodedPayload['subject'][0]['digest']['sha256'] === '' + ) { + throw FailedToVerifyRelease::fromInvalidSubjectDefinition(); + } + + $pharFilename->verifyAgainstOther(new BinaryFile( + $pharFilename->filePath, + $decodedPayload['subject'][0]['digest']['sha256'], + )); + } + + /** @return non-empty-list */ + private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array + { + $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; + + try { + $decodedJson = $this->httpDownloader->get( + $attestationUrl, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $attestationUrl), + ], + ], + )->decodeJson(); + + Assert::isArray($decodedJson); + Assert::keyExists($decodedJson, 'attestations'); + Assert::isNonEmptyList($decodedJson['attestations']); + + return array_map( + static function (array $attestation): Attestation { + return Attestation::fromAttestationBundleWithDsseEnvelope($attestation); + }, + $decodedJson['attestations'], + ); + } catch (TransportException $transportException) { + if ($transportException->getStatusCode() === 404) { + throw FailedToVerifyRelease::fromMissingAttestation($releaseMetadata, $pharFilename); + } + + throw $transportException; + } + } +} diff --git a/src/SelfManage/Verify/GithubCliAttestationVerification.php b/src/SelfManage/Verify/GithubCliAttestationVerification.php new file mode 100644 index 00000000..a4bfe0ff --- /dev/null +++ b/src/SelfManage/Verify/GithubCliAttestationVerification.php @@ -0,0 +1,27 @@ +verifyUsingGhCli(); - - if (! extension_loaded('openssl')) { - throw FailedToVerifyRelease::fromNoOpenssl(); - } - - $this->verifyUsingOpenSSL($releaseMetadata, $pharFilename, $output); - } - - private function verifyUsingGhCli(): void - { - // @todo verify using `gh attestation verify` etc - } - - private function verifyUsingOpenSSL(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void - { - $output->writeln( - 'Falling back to basic verification. To use full verification, install the `gh` CLI tool.', - OutputInterface::VERBOSITY_VERBOSE, - ); - - $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename); - - foreach ($attestations as $attestationIndex => $attestation) { - /** - * Useful references. Whilst we don't do the full verification that - * `gh attestation verify` would (since we don't want to re-invent - * the wheel), we can do some basic check of the DSSE Envelope. - * We'll check the payload digest matches our expectation, and - * verify the signature with the certificate. - * - * - https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256 - * - https://docs.sigstore.dev/logging/verify-release/ - * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol - */ - $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation); - $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE); - - $this->verifyDsseEnvelopeSignature($releaseMetadata, $attestationIndex, $attestation); - $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE); - } - - $output->writeln('✅ Verified the new PIE (using fallback verification)'); - } + $this->githubCliVerification->verify($releaseMetadata, $pharFilename, $output); - private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void - { if (! extension_loaded('openssl')) { throw FailedToVerifyRelease::fromNoOpenssl(); } - $publicKey = openssl_pkey_get_public($attestation->certificate); - Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class); - - $preAuthenticationEncoding = sprintf( - 'DSSEv1 %d %s %d %s', - strlen($attestation->dsseEnvelopePayloadType), - $attestation->dsseEnvelopePayloadType, - strlen($attestation->dsseEnvelopePayload), - $attestation->dsseEnvelopePayload, - ); - - if (openssl_verify($preAuthenticationEncoding, $attestation->dsseEnvelopeSignature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { - throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata); - } - } - - private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, Attestation $attestation): void - { - /** @var mixed $decodedPayload */ - $decodedPayload = json_decode($attestation->dsseEnvelopePayload, true); - - if ( - ! is_array($decodedPayload) - || ! array_key_exists('subject', $decodedPayload) - || ! is_array($decodedPayload['subject']) - || count($decodedPayload['subject']) !== 1 - || ! array_key_exists(0, $decodedPayload['subject']) - || ! is_array($decodedPayload['subject'][0]) - || ! array_key_exists('name', $decodedPayload['subject'][0]) - || $decodedPayload['subject'][0]['name'] !== 'pie.phar' - || ! array_key_exists('digest', $decodedPayload['subject'][0]) - || ! is_array($decodedPayload['subject'][0]['digest']) - || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest']) - || ! is_string($decodedPayload['subject'][0]['digest']['sha256']) - || $decodedPayload['subject'][0]['digest']['sha256'] === '' - ) { - throw FailedToVerifyRelease::fromInvalidSubjectDefinition(); - } - - $pharFilename->verifyAgainstOther(new BinaryFile( - $pharFilename->filePath, - $decodedPayload['subject'][0]['digest']['sha256'], - )); - } - - /** @return non-empty-list */ - private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array - { - $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum; - - try { - $decodedJson = $this->httpDownloader->get( - $attestationUrl, - [ - 'retry-auth-failure' => false, - 'http' => [ - 'method' => 'GET', - 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $attestationUrl), - ], - ], - )->decodeJson(); - - Assert::isArray($decodedJson); - Assert::keyExists($decodedJson, 'attestations'); - Assert::isNonEmptyList($decodedJson['attestations']); - - return array_map( - static function (array $attestation): Attestation { - return Attestation::fromAttestationBundleWithDsseEnvelope($attestation); - }, - $decodedJson['attestations'], - ); - } catch (TransportException $transportException) { - if ($transportException->getStatusCode() === 404) { - throw FailedToVerifyRelease::fromMissingAttestation($releaseMetadata, $pharFilename); - } - - throw $transportException; - } + $this->fallbackVerification->verify($releaseMetadata, $pharFilename, $output); } } From 19095eb0beeec5bc9a9e84a94488370ab7368b43 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 20 Mar 2025 09:25:51 +0000 Subject: [PATCH 08/15] Introduce VerifyPieReleaseUsingAttestation factory --- src/Command/SelfUpdateCommand.php | 6 ++---- .../Verify/GithubCliAttestationVerification.php | 4 ++++ .../Verify/VerifyPieReleaseUsingAttestation.php | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 484b8e8a..64d14922 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -38,6 +38,7 @@ )] final class SelfUpdateCommand extends Command { + /** @param non-empty-string $githubApiBaseUrl */ public function __construct( private readonly string $githubApiBaseUrl, private readonly QuieterConsoleIO $io, @@ -74,10 +75,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $httpDownloader = new HttpDownloader($this->io, $composer->getConfig()); $authHelper = new AuthHelper($this->io, $composer->getConfig()); $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); - $verifyPiePhar = new VerifyPieReleaseUsingAttestation( - new GithubCliAttestationVerification($this->githubApiBaseUrl, $httpDownloader, $authHelper), - new FallbackVerificationUsingOpenSsl($this->githubApiBaseUrl, $httpDownloader, $authHelper), - ); + $verifyPiePhar = VerifyPieReleaseUsingAttestation::factory($this->githubApiBaseUrl, $httpDownloader, $authHelper); $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); $pieVersion = PieVersion::get(); diff --git a/src/SelfManage/Verify/GithubCliAttestationVerification.php b/src/SelfManage/Verify/GithubCliAttestationVerification.php index a4bfe0ff..fdf05c21 100644 --- a/src/SelfManage/Verify/GithubCliAttestationVerification.php +++ b/src/SelfManage/Verify/GithubCliAttestationVerification.php @@ -9,14 +9,18 @@ use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\ExecutableFinder; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class GithubCliAttestationVerification implements VerifyPiePhar { + private const GH_CLI_NAME = 'gh'; + public function __construct( private readonly string $githubApiBaseUrl, private readonly HttpDownloader $httpDownloader, private readonly AuthHelper $authHelper, + private readonly ExecutableFinder $executableFinder, ) { } diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index f0b6d6a2..ad5ebdc1 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -4,9 +4,12 @@ namespace Php\Pie\SelfManage\Verify; +use Composer\Util\AuthHelper; +use Composer\Util\HttpDownloader; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\ExecutableFinder; use function extension_loaded; @@ -19,6 +22,18 @@ public function __construct( ) { } + /** @param non-empty-string $githubApiBaseUrl */ + public static function factory( + string $githubApiBaseUrl, + HttpDownloader $httpDownloader, + AuthHelper $authHelper, + ): self { + return new VerifyPieReleaseUsingAttestation( + new GithubCliAttestationVerification($githubApiBaseUrl, $httpDownloader, $authHelper, new ExecutableFinder()), + new FallbackVerificationUsingOpenSsl($githubApiBaseUrl, $httpDownloader, $authHelper), + ); + } + public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void { $this->githubCliVerification->verify($releaseMetadata, $pharFilename, $output); From 4cb492395be1e07ab83d189c3c3a3fd6a6194dec Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 20 Mar 2025 11:00:30 +0000 Subject: [PATCH 09/15] Added GH CLI verification using gh --- src/Command/SelfUpdateCommand.php | 3 -- .../Verify/FailedToVerifyRelease.php | 14 ++++++ .../FallbackVerificationUsingOpenSsl.php | 2 +- .../GithubCliAttestationVerification.php | 43 ++++++++++++++----- .../Verify/GithubCliNotAvailable.php | 17 ++++++++ .../VerifyPieReleaseUsingAttestation.php | 16 ++++--- 6 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 src/SelfManage/Verify/GithubCliNotAvailable.php diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 64d14922..02a00d12 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -13,8 +13,6 @@ use Php\Pie\File\SudoFilePut; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; -use Php\Pie\SelfManage\Verify\FallbackVerificationUsingOpenSsl; -use Php\Pie\SelfManage\Verify\GithubCliAttestationVerification; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Util\PieVersion; use Psr\Container\ContainerInterface; @@ -79,7 +77,6 @@ public function execute(InputInterface $input, OutputInterface $output): int $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); $pieVersion = PieVersion::get(); - $pieVersion = '0.7.0'; // @todo for testing only $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 4204ec9b..2cf32a24 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -7,8 +7,10 @@ use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use RuntimeException; +use Symfony\Component\Process\Exception\ProcessFailedException; use function sprintf; +use function trim; class FailedToVerifyRelease extends RuntimeException { @@ -39,4 +41,16 @@ public static function fromNoOpenssl(): self { return new self('Unable to verify without `gh` CLI tool, or openssl extension.'); } + + public static function fromGhCliFailure(ReleaseMetadata $releaseMetadata, ProcessFailedException $processFailedException): self + { + return new self( + sprintf( + "`gh` CLI tool could not verify release %s\n\nError: %s", + $releaseMetadata->tag, + trim($processFailedException->getProcess()->getErrorOutput()), + ), + previous: $processFailedException, + ); + } } diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index b3c4f04c..0c90cb82 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -65,7 +65,7 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE); } - $output->writeln('✅ Verified the new PIE (using fallback verification)'); + $output->writeln('✅ Verified the new PIE version (using fallback verification)'); } private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void diff --git a/src/SelfManage/Verify/GithubCliAttestationVerification.php b/src/SelfManage/Verify/GithubCliAttestationVerification.php index fdf05c21..901246d5 100644 --- a/src/SelfManage/Verify/GithubCliAttestationVerification.php +++ b/src/SelfManage/Verify/GithubCliAttestationVerification.php @@ -4,28 +4,49 @@ namespace Php\Pie\SelfManage\Verify; -use Composer\Util\AuthHelper; -use Composer\Util\HttpDownloader; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; +use Php\Pie\Util\Process; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\ExecutableFinder; +use function implode; + /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class GithubCliAttestationVerification implements VerifyPiePhar { - private const GH_CLI_NAME = 'gh'; - - public function __construct( - private readonly string $githubApiBaseUrl, - private readonly HttpDownloader $httpDownloader, - private readonly AuthHelper $authHelper, - private readonly ExecutableFinder $executableFinder, - ) { + private const GH_CLI_NAME = 'gh'; + private const GH_VERIFICATION_TIMEOUT = 30; + + public function __construct(private readonly ExecutableFinder $executableFinder) + { } public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void { - // @todo verify using `gh attestation verify` etc + $gh = $this->executableFinder->find(self::GH_CLI_NAME); + + if ($gh === null) { + throw GithubCliNotAvailable::fromExpectedGhToolName(self::GH_CLI_NAME); + } + + $verificationCommand = [ + $gh, + 'attestation', + 'verify', + '--owner=php', + $pharFilename->filePath, + ]; + + $output->writeln('Verifying using: ' . implode(' ', $verificationCommand), OutputInterface::VERBOSITY_VERBOSE); + + try { + Process::run($verificationCommand, null, self::GH_VERIFICATION_TIMEOUT); + } catch (ProcessFailedException $processFailedException) { + throw FailedToVerifyRelease::fromGhCliFailure($releaseMetadata, $processFailedException); + } + + $output->writeln('✅ Verified the new PIE version'); } } diff --git a/src/SelfManage/Verify/GithubCliNotAvailable.php b/src/SelfManage/Verify/GithubCliNotAvailable.php new file mode 100644 index 00000000..a71d952d --- /dev/null +++ b/src/SelfManage/Verify/GithubCliNotAvailable.php @@ -0,0 +1,17 @@ +githubCliVerification->verify($releaseMetadata, $pharFilename, $output); + try { + $this->githubCliVerification->verify($releaseMetadata, $pharFilename, $output); + } catch (GithubCliNotAvailable $githubCliNotAvailable) { + $output->writeln($githubCliNotAvailable->getMessage(), OutputInterface::VERBOSITY_VERBOSE); - if (! extension_loaded('openssl')) { - throw FailedToVerifyRelease::fromNoOpenssl(); - } + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); + } - $this->fallbackVerification->verify($releaseMetadata, $pharFilename, $output); + $this->fallbackVerification->verify($releaseMetadata, $pharFilename, $output); + } } } From 587671872bdfec0c5214aafc86becde8899a2ae3 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 21 Mar 2025 08:48:55 +0000 Subject: [PATCH 10/15] Handle logic for nightly releases if requested --- src/Command/SelfUpdateCommand.php | 56 +++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 02a00d12..4faf1e50 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -12,6 +12,7 @@ use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\File\SudoFilePut; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; +use Php\Pie\SelfManage\Update\ReleaseMetadata; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Util\PieVersion; @@ -19,6 +20,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function file_get_contents; @@ -36,6 +38,8 @@ )] final class SelfUpdateCommand extends Command { + private const OPTION_NIGHTLY_UPDATE = 'nightly'; + /** @param non-empty-string $githubApiBaseUrl */ public function __construct( private readonly string $githubApiBaseUrl, @@ -50,6 +54,12 @@ public function configure(): void parent::configure(); CommandHelper::configurePhpConfigOptions($this); + $this->addOption( + self::OPTION_NIGHTLY_UPDATE, + null, + InputOption::VALUE_NONE, + 'Update to the latest nightly version.', + ); } public function execute(InputInterface $input, OutputInterface $output): int @@ -75,22 +85,40 @@ public function execute(InputInterface $input, OutputInterface $output): int $fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper); $verifyPiePhar = VerifyPieReleaseUsingAttestation::factory($this->githubApiBaseUrl, $httpDownloader, $authHelper); - $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); - $pieVersion = PieVersion::get(); - - $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); - - if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) { - $output->writeln('You already have the latest version 😍'); - - return Command::SUCCESS; + if ($input->hasOption(self::OPTION_NIGHTLY_UPDATE) && $input->getOption(self::OPTION_NIGHTLY_UPDATE)) { + $latestRelease = new ReleaseMetadata( + 'nightly', + 'https://php.github.io/pie/pie-nightly.phar', + ); + + $output->writeln('Downloading the latest nightly release.'); + } else { + $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata(); + $pieVersion = PieVersion::get(); + + if (preg_match('/^(?.+)@(?[a-f0-9]{7})$/', $pieVersion, $matches)) { + // Have to change the version to something the Semver library understands + $pieVersion = sprintf('dev-main#%s', $matches['hash']); + $output->writeln(sprintf( + 'It looks like you are running a nightly build; if you want to get the newest nightly, specify the --%s flag.', + self::OPTION_NIGHTLY_UPDATE, + )); + } + + $output->writeln(sprintf('You are currently running PIE version %s', $pieVersion)); + + if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) { + $output->writeln('You already have the latest version 😍'); + + return Command::SUCCESS; + } + + $output->writeln( + sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag), + OutputInterface::VERBOSITY_VERBOSE, + ); } - $output->writeln( - sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag), - OutputInterface::VERBOSITY_VERBOSE, - ); - $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease); $output->writeln( From bdf6e42c07a54e6ef754eb3706cd85587ebce60a Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 21 Mar 2025 11:41:58 +0000 Subject: [PATCH 11/15] Unit tests for the new verify/update methods --- test/assets/fake-gh-cli/happy.sh | 3 + test/assets/fake-gh-cli/unhappy.sh | 3 + .../Update/FetchPieReleaseFromGitHubTest.php | 117 +++++++++ .../SelfManage/Verify/AttestationTest.php | 176 +++++++++++++ .../FallbackVerificationUsingOpenSslTest.php | 232 ++++++++++++++++++ .../GithubCliAttestationVerificationTest.php | 68 +++++ 6 files changed, 599 insertions(+) create mode 100755 test/assets/fake-gh-cli/happy.sh create mode 100755 test/assets/fake-gh-cli/unhappy.sh create mode 100644 test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php create mode 100644 test/unit/SelfManage/Verify/AttestationTest.php create mode 100644 test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php create mode 100644 test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php diff --git a/test/assets/fake-gh-cli/happy.sh b/test/assets/fake-gh-cli/happy.sh new file mode 100755 index 00000000..0fdcf091 --- /dev/null +++ b/test/assets/fake-gh-cli/happy.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exit 0 diff --git a/test/assets/fake-gh-cli/unhappy.sh b/test/assets/fake-gh-cli/unhappy.sh new file mode 100755 index 00000000..f019ff95 --- /dev/null +++ b/test/assets/fake-gh-cli/unhappy.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exit 1 diff --git a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php new file mode 100644 index 00000000..73f245d6 --- /dev/null +++ b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php @@ -0,0 +1,117 @@ +createMock(HttpDownloader::class); + $authHelper = $this->createMock(AuthHelper::class); + + $url = self::TEST_GITHUB_URL . '/repos/php/pie/releases/latest'; + $authHelper + ->method('addAuthenticationHeader') + ->willReturn(['Authorization: Bearer fake-token']); + $httpDownloader->expects(self::once()) + ->method('get') + ->with( + $url, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => ['Authorization: Bearer fake-token'], + ], + ], + ) + ->willReturn( + new Response( + ['url' => $url], + 200, + [], + json_encode([ + 'tag_name' => '1.2.3', + 'assets' => [ + [ + 'name' => 'not-pie.phar', + 'browser_download_url' => self::TEST_GITHUB_URL . '/do/not/download/this', + ], + [ + 'name' => 'pie.phar', + 'browser_download_url' => self::TEST_GITHUB_URL . '/path/to/pie.phar', + ], + ], + ]), + ), + ); + + $fetch = new FetchPieReleaseFromGitHub(self::TEST_GITHUB_URL, $httpDownloader, $authHelper); + + $latestRelease = $fetch->latestReleaseMetadata(); + + self::assertSame('1.2.3', $latestRelease->tag); + self::assertSame(self::TEST_GITHUB_URL . '/path/to/pie.phar', $latestRelease->downloadUrl); + } + + public function testDownloadContent(): void + { + $url = self::TEST_GITHUB_URL . '/path/to/pie.phar'; + $pharContent = uniqid('pharContent', true); + $expectedDigest = hash('sha256', $pharContent); + + $latestRelease = new ReleaseMetadata('1.2.3', $url); + + $httpDownloader = $this->createMock(HttpDownloader::class); + $authHelper = $this->createMock(AuthHelper::class); + + $authHelper + ->method('addAuthenticationHeader') + ->willReturn(['Authorization: Bearer fake-token']); + $httpDownloader->expects(self::once()) + ->method('get') + ->with( + $url, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => ['Authorization: Bearer fake-token'], + ], + ], + ) + ->willReturn( + new Response( + ['url' => $url], + 200, + [], + $pharContent, + ), + ); + + $fetch = new FetchPieReleaseFromGitHub(self::TEST_GITHUB_URL, $httpDownloader, $authHelper); + + $file = $fetch->downloadContent($latestRelease); + + self::assertSame($pharContent, file_get_contents($file->filePath)); + self::assertSame($expectedDigest, $file->checksum); + } +} diff --git a/test/unit/SelfManage/Verify/AttestationTest.php b/test/unit/SelfManage/Verify/AttestationTest.php new file mode 100644 index 00000000..3435bbd2 --- /dev/null +++ b/test/unit/SelfManage/Verify/AttestationTest.php @@ -0,0 +1,176 @@ + [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ]); + + self::assertSame( + "-----BEGIN CERTIFICATE-----\n" + . "some great certificate content. some great certificate content.\n" + . "some great certificate content.\n" + . "-----END CERTIFICATE-----\n", + $attestation->certificate, + ); + self::assertSame('this is the amazing payload', $attestation->dsseEnvelopePayload); + self::assertSame('this is the payload type', $attestation->dsseEnvelopePayloadType); + self::assertSame('signature number one!', $attestation->dsseEnvelopeSignature); + } + + /** + * @return array + * + * @psalm-suppress PossiblyUnusedMethod https://github.com/psalm/psalm-plugin-phpunit/issues/131 + */ + public static function invalidBundleProvider(): array + { + return [ + [ + [], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => ''], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => [], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => '', + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => '', + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'signatures' => [['sig' => base64_encode('signature number one!')]], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => 'this is the payload type', + 'signatures' => [['sig' => '']], + ], + ], + ], + ], + [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode('this is the amazing payload'), + 'payloadType' => 'this is the payload type', + 'signatures' => [], + ], + ], + ], + ], + ]; + } + + /** @param array $invalidBundle */ + #[DataProvider('invalidBundleProvider')] + public function testFromAttestationWithInvalidBundles(array $invalidBundle): void + { + self::expectException(InvalidArgumentException::class); + Attestation::fromAttestationBundleWithDsseEnvelope($invalidBundle); + } +} diff --git a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php new file mode 100644 index 00000000..bc6d09ae --- /dev/null +++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php @@ -0,0 +1,232 @@ +release = new ReleaseMetadata('1.2.3', self::TEST_GITHUB_URL . '/pie.phar'); + $this->downloadedPhar = new BinaryFile('/path/to/pie.phar', 'fake-checksum'); + + $this->httpDownloader = $this->createMock(HttpDownloader::class); + $this->authHelper = $this->createMock(AuthHelper::class); + $this->output = new BufferedOutput(); + + $this->verifier = new FallbackVerificationUsingOpenSsl(self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper); + } + + /** @return array{0: string, 1: string} */ + private function prepareCertificateAndSignature(string $dsseEnvelopePayload): array + { + $privateKey = openssl_pkey_new(); + $csr = openssl_csr_new(['commonName' => 'pie-test'], $privateKey); + $certificate = openssl_csr_sign($csr, null, $privateKey, 1); + openssl_x509_export($certificate, $pemCertificate); + + openssl_sign( + sprintf( + 'DSSEv1 %d %s %d %s', + strlen(self::DSSE_PAYLOAD_TYPE), + self::DSSE_PAYLOAD_TYPE, + strlen($dsseEnvelopePayload), + $dsseEnvelopePayload, + ), + $signature, + $privateKey, + OPENSSL_ALGO_SHA256, + ); + + return [$pemCertificate, $signature]; + } + + private function mockAttestationResponse(string $digestInUrl, string $dsseEnvelopePayload, string $signature, string $pemCertificate): void + { + $url = self::TEST_GITHUB_URL . '/orgs/php/attestations/sha256:' . $digestInUrl; + $this->authHelper + ->method('addAuthenticationHeader') + ->willReturn(['Authorization: Bearer fake-token']); + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + $url, + [ + 'retry-auth-failure' => false, + 'http' => [ + 'method' => 'GET', + 'header' => ['Authorization: Bearer fake-token'], + ], + ], + ) + ->willReturn( + new Response( + ['url' => $url], + 200, + [], + json_encode([ + 'attestations' => [ + [ + 'bundle' => [ + 'verificationMaterial' => [ + 'certificate' => ['rawBytes' => trim(str_replace('-----BEGIN CERTIFICATE-----', '', str_replace('-----END CERTIFICATE-----', '', $pemCertificate)))], + ], + 'dsseEnvelope' => [ + 'payload' => base64_encode($dsseEnvelopePayload), + 'payloadType' => self::DSSE_PAYLOAD_TYPE, + 'signatures' => [['sig' => base64_encode($signature)]], + ], + ], + ], + ], + ]), + ), + ); + } + + public function testSuccessfulVerify(): void + { + if (! extension_loaded('openssl')) { + self::markTestSkipped('Cannot run tests without openssl extension'); + } + + $dsseEnvelopePayload = json_encode([ + 'subject' => [ + [ + 'name' => 'pie.phar', + 'digest' => ['sha256' => $this->downloadedPhar->checksum], + ], + ], + ]); + + [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload); + + $this->mockAttestationResponse($this->downloadedPhar->checksum, $dsseEnvelopePayload, $signature, $pemCertificate); + + $this->verifier->verify($this->release, $this->downloadedPhar, $this->output); + + self::assertStringContainsString('Verified the new PIE version (using fallback verification)', $this->output->fetch()); + } + + public function testFailedToVerifyBecauseDigestMismatch(): void + { + if (! extension_loaded('openssl')) { + self::markTestSkipped('Cannot run tests without openssl extension'); + } + + $dsseEnvelopePayload = json_encode([ + 'subject' => [ + [ + 'name' => 'pie.phar', + 'digest' => ['sha256' => 'different-checksum'], + ], + ], + ]); + + [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload); + + $this->mockAttestationResponse($this->downloadedPhar->checksum, $dsseEnvelopePayload, $signature, $pemCertificate); + + $this->expectException(BinaryFileFailedVerification::class); + $this->verifier->verify($this->release, $this->downloadedPhar, $this->output); + } + + public function testFailedToVerifyBecauseSignatureVerificationFailed(): void + { + if (! extension_loaded('openssl')) { + self::markTestSkipped('Cannot run tests without openssl extension'); + } + + $dsseEnvelopePayload = json_encode([ + 'subject' => [ + [ + 'name' => 'pie.phar', + 'digest' => ['sha256' => $this->downloadedPhar->checksum], + ], + ], + ]); + + [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload); + + $this->mockAttestationResponse( + $this->downloadedPhar->checksum, + json_encode([ + 'subject' => [ + [ + 'name' => 'pie.phar', + 'digest' => ['sha256' => $this->downloadedPhar->checksum], + 'i-tampered-with-this-payload-hahahaha' => true, + ], + ], + ]), + $signature, + $pemCertificate, + ); + + $this->expectException(FailedToVerifyRelease::class); + $this->verifier->verify($this->release, $this->downloadedPhar, $this->output); + } + + public function testFailedToVerifyBecauseDigestNotFoundOnGitHub(): void + { + if (! extension_loaded('openssl')) { + self::markTestSkipped('Cannot run tests without openssl extension'); + } + + $transportException = new TransportException('404 Not Found'); + $transportException->setStatusCode(404); + + $this->authHelper + ->method('addAuthenticationHeader') + ->willReturn(['Authorization: Bearer fake-token']); + $this->httpDownloader->expects(self::once()) + ->method('get') + ->willThrowException($transportException); + + $this->expectException(FailedToVerifyRelease::class); + $this->verifier->verify($this->release, $this->downloadedPhar, $this->output); + } +} diff --git a/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php new file mode 100644 index 00000000..4dda75fb --- /dev/null +++ b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php @@ -0,0 +1,68 @@ +executableFinder = $this->createMock(ExecutableFinder::class); + $this->output = new BufferedOutput(); + + $this->verifier = new GithubCliAttestationVerification($this->executableFinder); + } + + public function testPassingVerification(): void + { + $this->executableFinder + ->method('find') + ->willReturn(self::FAKE_GH_CLI_HAPPY); + + $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output); + + self::assertStringContainsString('Verified the new PIE version', $this->output->fetch()); + } + + public function testCannotFindGhCli(): void + { + $this->executableFinder + ->method('find') + ->willReturn(null); + + $this->expectException(GithubCliNotAvailable::class); + $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output); + } + + public function testFailingVerification(): void + { + $this->executableFinder + ->method('find') + ->willReturn(self::FAKE_GH_CLI_UNHAPPY); + + $this->expectException(FailedToVerifyRelease::class); + $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output); + } +} From 08f5ef5005a1608f055fd9eef935a3bc0488001b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 24 Mar 2025 08:48:49 +0000 Subject: [PATCH 12/15] Use BAT files for Windows tests for exit differences --- test/assets/fake-gh-cli/happy.bat | 2 ++ test/assets/fake-gh-cli/happy.sh | 1 + test/assets/fake-gh-cli/unhappy.bat | 2 ++ test/assets/fake-gh-cli/unhappy.sh | 1 + .../Verify/GithubCliAttestationVerificationTest.php | 11 +++++++---- 5 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 test/assets/fake-gh-cli/happy.bat create mode 100644 test/assets/fake-gh-cli/unhappy.bat diff --git a/test/assets/fake-gh-cli/happy.bat b/test/assets/fake-gh-cli/happy.bat new file mode 100644 index 00000000..128249e7 --- /dev/null +++ b/test/assets/fake-gh-cli/happy.bat @@ -0,0 +1,2 @@ +echo "Pretending to be gh cli - happy path" +exit /b 0 diff --git a/test/assets/fake-gh-cli/happy.sh b/test/assets/fake-gh-cli/happy.sh index 0fdcf091..d0f8e4d9 100755 --- a/test/assets/fake-gh-cli/happy.sh +++ b/test/assets/fake-gh-cli/happy.sh @@ -1,3 +1,4 @@ #!/usr/bin/env bash +echo "Pretending to be gh cli - happy path" exit 0 diff --git a/test/assets/fake-gh-cli/unhappy.bat b/test/assets/fake-gh-cli/unhappy.bat new file mode 100644 index 00000000..d3acb36e --- /dev/null +++ b/test/assets/fake-gh-cli/unhappy.bat @@ -0,0 +1,2 @@ +echo "Pretending to be gh cli - unhappy path" +exit /b 1 diff --git a/test/assets/fake-gh-cli/unhappy.sh b/test/assets/fake-gh-cli/unhappy.sh index f019ff95..df58d24d 100755 --- a/test/assets/fake-gh-cli/unhappy.sh +++ b/test/assets/fake-gh-cli/unhappy.sh @@ -1,3 +1,4 @@ #!/usr/bin/env bash +echo "Pretending to be gh cli - unhappy path" exit 1 diff --git a/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php index 4dda75fb..ad9863d4 100644 --- a/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php +++ b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php @@ -4,6 +4,7 @@ namespace Php\PieUnitTest\SelfManage\Verify; +use Composer\Util\Platform; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; @@ -18,8 +19,10 @@ #[CoversClass(GithubCliAttestationVerification::class)] final class GithubCliAttestationVerificationTest extends TestCase { - private const FAKE_GH_CLI_HAPPY = __DIR__ . '/../../../assets/fake-gh-cli/happy.sh'; - private const FAKE_GH_CLI_UNHAPPY = __DIR__ . '/../../../assets/fake-gh-cli/unhappy.sh'; + private const FAKE_GH_CLI_HAPPY_SH = __DIR__ . '/../../../assets/fake-gh-cli/happy.sh'; + private const FAKE_GH_CLI_UNHAPPY_SH = __DIR__ . '/../../../assets/fake-gh-cli/unhappy.sh'; + private const FAKE_GH_CLI_HAPPY_BAT = __DIR__ . '/../../../assets/fake-gh-cli/happy.bat'; + private const FAKE_GH_CLI_UNHAPPY_BAT = __DIR__ . '/../../../assets/fake-gh-cli/unhappy.bat'; private ExecutableFinder&MockObject $executableFinder; private BufferedOutput $output; @@ -39,7 +42,7 @@ public function testPassingVerification(): void { $this->executableFinder ->method('find') - ->willReturn(self::FAKE_GH_CLI_HAPPY); + ->willReturn(Platform::isWindows() ? self::FAKE_GH_CLI_HAPPY_BAT : self::FAKE_GH_CLI_HAPPY_SH); $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output); @@ -60,7 +63,7 @@ public function testFailingVerification(): void { $this->executableFinder ->method('find') - ->willReturn(self::FAKE_GH_CLI_UNHAPPY); + ->willReturn(Platform::isWindows() ? self::FAKE_GH_CLI_UNHAPPY_BAT : self::FAKE_GH_CLI_UNHAPPY_SH); $this->expectException(FailedToVerifyRelease::class); $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output); From 37807de9242876dc7d03102f54a48d1573fa3e60 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 1 Apr 2025 12:47:26 +0100 Subject: [PATCH 13/15] Verify facts about the attestation certificate and its authenticity using a trusted root store --- composer.json | 1 + composer.lock | 10 +- resources/trusted-root.jsonl | 2 + .../Verify/FailedToVerifyRelease.php | 30 ++++ .../FallbackVerificationUsingOpenSsl.php | 145 ++++++++++++++++++ .../VerifyPieReleaseUsingAttestation.php | 3 +- .../FallbackVerificationUsingOpenSslTest.php | 57 ++++++- 7 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 resources/trusted-root.jsonl diff --git a/composer.json b/composer.json index 7fa90b15..12d276b9 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "webmozart/assert": "^1.11" }, "require-dev": { + "ext-openssl": "*", "behat/behat": "^3.19.0", "doctrine/coding-standard": "^13.0", "phpunit/phpunit": "^10.5.45", diff --git a/composer.lock b/composer.lock index a77cbf3d..32efbe12 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": "7797d21f9607be632029637ff4113a8f", + "content-hash": "0bd8be5338832e97b188b0ed6132093a", "packages": [ { "name": "composer/ca-bundle", @@ -7019,15 +7019,17 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": "8.1.*||8.2.*||8.3.*||8.4.*" }, - "platform-dev": [], + "platform-dev": { + "ext-openssl": "*" + }, "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/resources/trusted-root.jsonl b/resources/trusted-root.jsonl new file mode 100644 index 00000000..384a0321 --- /dev/null +++ b/resources/trusted-root.jsonl @@ -0,0 +1,2 @@ +{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[{"baseUrl":"https://rekor.sigstore.dev","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-01-12T11:53:27.000Z"}},"logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}}],"certificateAuthorities":[{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},"validFor":{"start":"2021-03-07T03:20:29.000Z","end":"2022-12-31T23:59:59.999Z"}},{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="},{"rawBytes":"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"}]},"validFor":{"start":"2022-04-13T20:06:15.000Z"}}],"ctlogs":[{"baseUrl":"https://ctfe.sigstore.dev/test","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-03-14T00:00:00.000Z","end":"2022-10-31T23:59:59.999Z"}},"logId":{"keyId":"CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I="}},{"baseUrl":"https://ctfe.sigstore.dev/2022","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2022-10-20T00:00:00.000Z"}},"logId":{"keyId":"3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4="}}]} +{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","certificateAuthorities":[{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKjCCAbCgAwIBAgIUW3TJVeOvr+NSvJXdOw8nEEn7HhQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTIzMDkxMjE0MDY1NFoXDTI0MDkxMTE0MDY1NFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEsosodObhuHG6Pr5vp5y+pmnKawS1h2hwv3r3hBwqh3ZHJAw64mhDnDs9fw4jKkZEBYRSVyOHyZppz4day8hgpTIDwdj44Oan4RDb+wmj04jfhVLjLsQ4Q/X4K/ynRgNXo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUm0vkDkQZ29hutYdayJobIRmf/iMwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaAAwZQIwIBl93E7vkWTvdeIm1WSIM4qNsj0ApE8LCj3k1vrY5x6/7yhAZs7QlO3/FBCoEeaZAjEAlJcNr37uZq9BYHODHBeO/gP+6EfbzsNaLV22ASBlhF/a9y83ESLuqCNN7IxGxmWT"},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2023-10-27T16:30:00Z","end":"2024-05-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKzCCAbCgAwIBAgIUOpyw2HaZefsj/4SPXutGof8E2CkwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI0MDUxMzAwMDAwMFoXDTI1MDUxMzAwMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJNJWvW8fckfk/oQmh+qCeIlFXl9YLEkKSjZCgcVB92Fi1HQnvmpCiyqpvP91SmT1/G6QbrmTGV7MmIQlDnBWHNUT+jwZ3elGu/yfr/v8U0uhZTIli/BMj5Y4ICHK/j4do3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUD0fF3cs+ldPyiWohHJ3JmO91V7gwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaQAwZgIxAO7BRC9i7oGUHjjlcHU/bfqk2NLy7t6wm3K5W+jBLFbAj6sVjYcY+rrYhop/OjclbQIxALafBKLPIPjoCI29BUHwLBFP6e92ZlyaoFtoqccceXAevRaDjXFvb5+M7wnD6AuAJw=="},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-05-13T00:00:00Z","end":"2024-10-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKzCCAbCgAwIBAgIUQeyd9UH06yZ63pDuqjgUZ58CnpMwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI0MTAwMzEyMDAwMFoXDTI1MTAwMzEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEwvbET2w+j9j9j50iTInH1gb9GSXkpsCvWz5orX1zgme+/Qh/5gMkpfmgfOSLV2ZRgT1hzujYmnKQvP2mCxYnbwQELAkAf+VhEY/7Uw3zZvguGQSdF1cxzRHiMTOha5eFo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUMib9z4ZYBcQANTVvVCa3KoTGbBUwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaQAwZgIxAPIU/zlJiJrxn6oTWNdEAD/YBSnhyxcvpq1D2DzFy8E8hbkEfMZPErYL7HyoL/BkdwIxAN9KDEKyktEUBrfHehfcLAzI2kERJx+8DSslXswOIbLaeqYfWsmrQAt5C0X/nOWxXA=="},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-10-07T00:00:00Z"}}],"timestampAuthorities":[{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICHDCCAaGgAwIBAgIUNDVlmtZuvoujn4KwiC/oxIr8hxAwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDgzMTEyMDAwMFoXDTI0MDgzMDEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEV/zJhNTdu0Fa9hGCUih/JvqEoE81tEWrAVwUXXhdRgIY9hIFErLhNo6sSOpV9d7Zuy0KWMHhcimCUr41a1732ByVRy3f+Z4QhqpsgFMh5b5J90HJLK7HOyUZjehAnvSno3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUGwE6T5ZIh6lY9wP6vt42UHyVMewwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaQAwZgIxAJo48LtrSsn0UmLtqGiUKg2EUvso+aDN5EyjpvMmobZ/Oq9zjnR7Of369hoABW4/1gIxANg5ZW4FqijhsXnA3md6jM9yLrLCI9QL+KnuZnXq6WgAcNQaAN7PNNjVDKV3iJEklw=="},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2023-10-27T16:30:00Z","end":"2024-05-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICGzCCAaGgAwIBAgIUPPgn6ner1PU/75CQ+62fdBuazaAwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTI0MDUxMzAwMDAwMFoXDTI1MDUxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEA0pG1mAC4qafk1JJuOoIvhnMME9XmBDxjGFreDLnyzaexIzRw+UHUFy8C2gE6Me+0tIGQt4Ftbu66NGmfvBkR6boPMYQSU2O5X5ykZBm/9LR/Aqz0lgmBy/OlXvTJjglo3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUDa4GVhd97Z2V8kiVl9DB0kC53CMwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaAAwZQIwEQHd++b7IBAAuqT2/1i/wXf1WM2XrkFF6qd1c3kFcBVvdLQyJ5KoyNUHnfCCVJROAjEA+FoASOEcARlU6RqVcif9JthHwzh6nNwz0AfAHvO8xantN/7HjiLmFrFEGR/g0kN/"},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-05-13T00:00:00Z","end":"2024-10-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICGzCCAaGgAwIBAgIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTI0MTAwNDEyMDAwMFoXDTI1MTAwNDEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEM7jdYNBTeD6hjym2/y73b50u2AFQsf8305Sr1NleOqamH9aWt6obhJQH3NoNUw9iFzHcDvafYWQFMu7SmOxS5n3aqwwfR8oJxKnEl36uCmGB+8TXS3B76SVTHEhG5rzOo3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUQvz9YbWX3S6a+jruBkhRBiE2RCkwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaAAwZQIxAI4dhu5iyx/g+z1vKAAWvHtebl1ZwsC+Vwgjm6Ttlq5yLNHHvYEnJ/h15Qv2IuXvdgIwZ8H/iy4lXsFJdFYSsB1/zavl24EgxSzxK/pCpihXMetYYDA/lX3xMyquisMx45rN"},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-10-07T00:00:00Z"}}]} diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 2cf32a24..0cf31dd7 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -9,6 +9,8 @@ use RuntimeException; use Symfony\Component\Process\Exception\ProcessFailedException; +use function implode; +use function is_array; use function sprintf; use function trim; @@ -37,6 +39,34 @@ public static function fromSignatureVerificationFailed(int $attestationIndex, Re )); } + /** @param array|string $issuer */ + public static function fromIssuerCertificateVerificationFailed(array|string $issuer): self + { + return new self(sprintf( + 'Failed to verify the attestation certificate was issued by trusted root %s', + is_array($issuer) ? implode(',', $issuer) : $issuer, + )); + } + + /** @param array|string $issuer */ + public static function fromNoIssuerCertificateInTrustedRoot(array|string $issuer): self + { + return new self(sprintf( + 'Could not find a trusted root certificate for issuer %s', + is_array($issuer) ? implode(',', $issuer) : $issuer, + )); + } + + public static function fromMismatchingExtensionValues(string $extension, string $expected, string $actual): self + { + return new self(sprintf( + 'Attestation certificate extension %s mismatch; expected "%s", was "%s"', + $extension, + $expected, + $actual, + )); + } + public static function fromNoOpenssl(): self { return new self('Unable to verify without `gh` CLI tool, or openssl extension.'); diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index 0c90cb82..e2abee20 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -7,6 +7,7 @@ use Composer\Downloader\TransportException; use Composer\Util\AuthHelper; use Composer\Util\HttpDownloader; +use DateTimeImmutable; use OpenSSLAsymmetricKey; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; @@ -16,21 +17,40 @@ use function array_key_exists; use function array_map; use function count; +use function explode; use function extension_loaded; +use function file_get_contents; use function is_array; use function is_string; use function json_decode; +use function mb_substr; use function openssl_pkey_get_public; use function openssl_verify; +use function openssl_x509_parse; +use function openssl_x509_verify; +use function ord; use function sprintf; use function strlen; +use function trim; +use function wordwrap; use const OPENSSL_ALGO_SHA256; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class FallbackVerificationUsingOpenSsl implements VerifyPiePhar { + public const TRUSTED_ROOT_FILE_PATH = __DIR__ . '/../../../resources/trusted-root.jsonl'; + + /** @link https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#136141572641--fulcio */ + private const ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES = [ + '1.3.6.1.4.1.57264.1.8' => 'https://token.actions.githubusercontent.com', + '1.3.6.1.4.1.57264.1.12' => 'https://github.com/php/pie', + '1.3.6.1.4.1.57264.1.16' => 'https://github.com/php', + ]; + public function __construct( + private readonly string $trustedRootFilePath, + private readonly DateTimeImmutable $now, private readonly string $githubApiBaseUrl, private readonly HttpDownloader $httpDownloader, private readonly AuthHelper $authHelper, @@ -58,6 +78,9 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam * - https://docs.sigstore.dev/logging/verify-release/ * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol */ + $this->assertCertificateClaims($attestation); + $output->writeln('#' . $attestationIndex . ': Certificate facts verified.', OutputInterface::VERBOSITY_VERBOSE); + $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation); $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE); @@ -68,6 +91,128 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam $output->writeln('✅ Verified the new PIE version (using fallback verification)'); } + private function assertCertificateClaims(Attestation $attestation): void + { + $attestationCertificateInfo = openssl_x509_parse($attestation->certificate); + Assert::isArray($attestationCertificateInfo['extensions']); + + /** + * See {@link https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#136141572641--fulcio} for details + * on the Fulcio extension keys; note the values are DER-encoded strings; the ASN.1 tag is UTF8String (0x0C). + * + * First up, check the extension values are what we expect; these are hard-coded, as we don't expect them + * to change unless the namespace/repo name change, etc. + */ + foreach (self::ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES as $extension => $expectedValue) { + Assert::keyExists($attestationCertificateInfo['extensions'], $extension); + Assert::stringNotEmpty($attestationCertificateInfo['extensions'][$extension]); + $actualValue = $attestationCertificateInfo['extensions'][$extension]; + + // First character (the ASN.1 tag) is expected to be UTF8String (0x0C) + if (ord($actualValue[0]) !== 12) { + throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $actualValue); + } + + // Second character is expected to be the length of the actual value + $derDecodedValue = mb_substr($actualValue, 2, ord($actualValue[1]), '8bit'); + if ($derDecodedValue !== $expectedValue) { + throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $derDecodedValue); + } + } + + // @todo process in place to make sure this gets updated frequently enough: gh attestation trusted-root > resources/trusted-root.jsonl + $trustedRootJsonLines = explode("\n", trim(file_get_contents($this->trustedRootFilePath))); + + /** + * Now go through our trusted root certificates and attempt to verify that the certificate was signed by an + * in-date trusted root certificate. The root certificates should be periodically and frequently updated using: + * + * gh attestation trusted-root > resources/trusted-root.jsonl + * + * And verifying the contents afterwards to ensure they have not been compromised. This list of JSON blobs may + * have multiple certificates (e.g. root certificates, intermediate certificates, expired certificates, etc.) + * so we should loop over to find the correct certificate used to sign the attestation certificate. + */ + foreach ($trustedRootJsonLines as $jsonLine) { + /** @var mixed $decoded */ + $decoded = json_decode($jsonLine, true); + + // No certificate authorities defined in this JSON line, skip it... + if (! is_array($decoded) || ! array_key_exists('certificateAuthorities', $decoded)) { + continue; + } + + /** @var mixed $certificateAuthority */ + foreach ($decoded['certificateAuthorities'] as $certificateAuthority) { + // We don't have a certificate chain defined, skip it... + if ( + ! is_array($certificateAuthority) + || ! array_key_exists('certChain', $certificateAuthority) + || ! is_array($certificateAuthority['certChain']) + || ! array_key_exists('certificates', $certificateAuthority['certChain']) + || ! is_array($certificateAuthority['certChain']['certificates']) + ) { + continue; + } + + /** @var mixed $caCertificateWrapper */ + foreach ($certificateAuthority['certChain']['certificates'] as $caCertificateWrapper) { + // Certificate is not in the expected format, i.e. no rawBytes key, skip it... + if ( + ! is_array($caCertificateWrapper) + || ! array_key_exists('rawBytes', $caCertificateWrapper) + || ! is_string($caCertificateWrapper['rawBytes']) + || $caCertificateWrapper['rawBytes'] === '' + ) { + continue; + } + + // Format the certificate, since OpenSSL doesn't accept the rawBytes directly + $caCertificateString = "-----BEGIN CERTIFICATE-----\n" + . wordwrap($caCertificateWrapper['rawBytes'], 67, "\n", true) . "\n" + . "-----END CERTIFICATE-----\n"; + + $caCertificateInfo = openssl_x509_parse($caCertificateString); + + // If the CA certificate subject is not the issuer of the attestation certificate, + // this was not the cert we were looking for, skip it... + if ($caCertificateInfo['subject'] !== $attestationCertificateInfo['issuer']) { + continue; + } + + // Verify the CA cert has not expired + Assert::keyExists($caCertificateInfo, 'validFrom_time_t'); + Assert::integer($caCertificateInfo['validFrom_time_t']); + Assert::keyExists($caCertificateInfo, 'validTo_time_t'); + Assert::integer($caCertificateInfo['validTo_time_t']); + + $caValidFrom = new DateTimeImmutable('@' . $caCertificateInfo['validFrom_time_t']); + $caValidTo = new DateTimeImmutable('@' . $caCertificateInfo['validTo_time_t']); + + if ($this->now < $caValidFrom || $this->now > $caValidTo) { + continue; + } + + // Finally, verify that the located CA cert was used to sign the attestation certificate + if (openssl_x509_verify($attestation->certificate, $caCertificateString) !== 1) { + /** @psalm-suppress MixedArgument */ + throw FailedToVerifyRelease::fromIssuerCertificateVerificationFailed($attestationCertificateInfo['issuer']); + } + + return; + } + } + } + + /** + * If we got here, we skipped all the certificates in the trusted root collection for various reasons; so we + * therefore cannot trust the attestation certificate. + * + * @psalm-suppress MixedArgument + */ + throw FailedToVerifyRelease::fromNoIssuerCertificateInTrustedRoot($attestationCertificateInfo['issuer']); + } + private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void { if (! extension_loaded('openssl')) { diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index f50bab19..e9eeb812 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -6,6 +6,7 @@ use Composer\Util\AuthHelper; use Composer\Util\HttpDownloader; +use DateTimeImmutable; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Symfony\Component\Console\Output\OutputInterface; @@ -30,7 +31,7 @@ public static function factory( ): self { return new VerifyPieReleaseUsingAttestation( new GithubCliAttestationVerification(new ExecutableFinder()), - new FallbackVerificationUsingOpenSsl($githubApiBaseUrl, $httpDownloader, $authHelper), + new FallbackVerificationUsingOpenSsl(FallbackVerificationUsingOpenSsl::TRUSTED_ROOT_FILE_PATH, new DateTimeImmutable(), $githubApiBaseUrl, $httpDownloader, $authHelper), ); } diff --git a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php index bc6d09ae..817a1a96 100644 --- a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php +++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php @@ -8,6 +8,8 @@ use Composer\Util\AuthHelper; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use DateTimeImmutable; +use DateTimeZone; use Php\Pie\File\BinaryFile; use Php\Pie\File\BinaryFileFailedVerification; use Php\Pie\SelfManage\Update\ReleaseMetadata; @@ -20,6 +22,7 @@ use function base64_encode; use function extension_loaded; +use function file_put_contents; use function json_encode; use function openssl_csr_new; use function openssl_csr_sign; @@ -29,6 +32,8 @@ use function sprintf; use function str_replace; use function strlen; +use function sys_get_temp_dir; +use function tempnam; use function trim; use const OPENSSL_ALGO_SHA256; @@ -45,6 +50,7 @@ final class FallbackVerificationUsingOpenSslTest extends TestCase private AuthHelper&MockObject $authHelper; private BufferedOutput $output; private FallbackVerificationUsingOpenSsl $verifier; + private string $trustedRootFilePath; public function setUp(): void { @@ -57,15 +63,60 @@ public function setUp(): void $this->authHelper = $this->createMock(AuthHelper::class); $this->output = new BufferedOutput(); - $this->verifier = new FallbackVerificationUsingOpenSsl(self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper); + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + $this->trustedRootFilePath = tempnam(sys_get_temp_dir(), 'pie_test_trusted_root_file_path'); + + $this->verifier = new FallbackVerificationUsingOpenSsl($this->trustedRootFilePath, $now, self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper); } /** @return array{0: string, 1: string} */ private function prepareCertificateAndSignature(string $dsseEnvelopePayload): array { + $caPrivateKey = openssl_pkey_new(); + $caCsr = openssl_csr_new(['CN' => 'pie-test-ca'], $caPrivateKey); + $caCert = openssl_csr_sign($caCsr, null, $caPrivateKey, 1); + openssl_x509_export($caCert, $caPemCertificate); + + file_put_contents($this->trustedRootFilePath, json_encode([ + 'mediaType' => 'application/vnd.dev.sigstore.trustedroot+json;version=0.1', + 'certificateAuthorities' => [ + [ + 'certChain' => [ + 'certificates' => [ + [ + 'rawBytes' => trim(str_replace('-----BEGIN CERTIFICATE-----', '', str_replace('-----END CERTIFICATE-----', '', $caPemCertificate))), + ], + ], + ], + ], + ], + ])); + + $tempOpensslConfig = tempnam(sys_get_temp_dir(), 'pie_openssl_test_config'); + file_put_contents($tempOpensslConfig, <<<'EOF' + +[ req ] +default_bits = 2048 +prompt = no +encrypt_key = no +default_md = sha1 +distinguished_name = dn +x509_extensions = v3_req + +[ dn ] + +[ v3_req ] +1.3.6.1.4.1.57264.1.8 = ASN1:UTF8String:https://token.actions.githubusercontent.com +1.3.6.1.4.1.57264.1.12 = ASN1:UTF8String:https://github.com/php/pie +1.3.6.1.4.1.57264.1.16 = ASN1:UTF8String:https://github.com/php +EOF); $privateKey = openssl_pkey_new(); - $csr = openssl_csr_new(['commonName' => 'pie-test'], $privateKey); - $certificate = openssl_csr_sign($csr, null, $privateKey, 1); + $csr = openssl_csr_new(['commonName' => 'pie-test'], $privateKey, ['config' => $tempOpensslConfig]); + $certificate = openssl_csr_sign($csr, $caCert, $caPrivateKey, 1, [ + 'config' => $tempOpensslConfig, + 'x509_extensions' => 'v3_req', + ]); openssl_x509_export($certificate, $pemCertificate); openssl_sign( From 5ddde91ba52d548fed56e6a289db1641de086c5e Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 1 Apr 2025 20:06:08 +0100 Subject: [PATCH 14/15] Improvements for readability and correctness from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Düsterhus --- .../FallbackVerificationUsingOpenSsl.php | 41 ++++++++----------- .../VerifyPieReleaseUsingAttestation.php | 3 +- .../SelfManage/Verify/AttestationTest.php | 2 +- .../FallbackVerificationUsingOpenSslTest.php | 6 +-- 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index e2abee20..09fe98ca 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -7,7 +7,6 @@ use Composer\Downloader\TransportException; use Composer\Util\AuthHelper; use Composer\Util\HttpDownloader; -use DateTimeImmutable; use OpenSSLAsymmetricKey; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; @@ -23,7 +22,6 @@ use function is_array; use function is_string; use function json_decode; -use function mb_substr; use function openssl_pkey_get_public; use function openssl_verify; use function openssl_x509_parse; @@ -31,6 +29,7 @@ use function ord; use function sprintf; use function strlen; +use function substr; use function trim; use function wordwrap; @@ -50,7 +49,6 @@ final class FallbackVerificationUsingOpenSsl implements VerifyPiePhar public function __construct( private readonly string $trustedRootFilePath, - private readonly DateTimeImmutable $now, private readonly string $githubApiBaseUrl, private readonly HttpDownloader $httpDownloader, private readonly AuthHelper $authHelper, @@ -109,12 +107,17 @@ private function assertCertificateClaims(Attestation $attestation): void $actualValue = $attestationCertificateInfo['extensions'][$extension]; // First character (the ASN.1 tag) is expected to be UTF8String (0x0C) - if (ord($actualValue[0]) !== 12) { + if (ord($actualValue[0]) !== 0x0C) { throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $actualValue); } - // Second character is expected to be the length of the actual value - $derDecodedValue = mb_substr($actualValue, 2, ord($actualValue[1]), '8bit'); + /** + * Second character is expected to be the length of the actual value + * as long as they are less than 127 bytes (short form) + * + * @link https://www.oss.com/asn1/resources/asn1-made-simple/asn1-quick-reference/basic-encoding-rules.html#Lengths + */ + $derDecodedValue = substr($actualValue, 2, ord($actualValue[1])); if ($derDecodedValue !== $expectedValue) { throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $derDecodedValue); } @@ -167,10 +170,15 @@ private function assertCertificateClaims(Attestation $attestation): void continue; } - // Format the certificate, since OpenSSL doesn't accept the rawBytes directly - $caCertificateString = "-----BEGIN CERTIFICATE-----\n" - . wordwrap($caCertificateWrapper['rawBytes'], 67, "\n", true) . "\n" - . "-----END CERTIFICATE-----\n"; + // Embed the base64-encoded DER into a PEM envelope for consumption by OpenSSL. + $caCertificateString = sprintf( + <<<'EOT' + -----BEGIN CERTIFICATE----- + %s + -----END CERTIFICATE----- + EOT, + wordwrap($caCertificateWrapper['rawBytes'], 67, "\n", true), + ); $caCertificateInfo = openssl_x509_parse($caCertificateString); @@ -180,19 +188,6 @@ private function assertCertificateClaims(Attestation $attestation): void continue; } - // Verify the CA cert has not expired - Assert::keyExists($caCertificateInfo, 'validFrom_time_t'); - Assert::integer($caCertificateInfo['validFrom_time_t']); - Assert::keyExists($caCertificateInfo, 'validTo_time_t'); - Assert::integer($caCertificateInfo['validTo_time_t']); - - $caValidFrom = new DateTimeImmutable('@' . $caCertificateInfo['validFrom_time_t']); - $caValidTo = new DateTimeImmutable('@' . $caCertificateInfo['validTo_time_t']); - - if ($this->now < $caValidFrom || $this->now > $caValidTo) { - continue; - } - // Finally, verify that the located CA cert was used to sign the attestation certificate if (openssl_x509_verify($attestation->certificate, $caCertificateString) !== 1) { /** @psalm-suppress MixedArgument */ diff --git a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php index e9eeb812..186d4f74 100644 --- a/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php +++ b/src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php @@ -6,7 +6,6 @@ use Composer\Util\AuthHelper; use Composer\Util\HttpDownloader; -use DateTimeImmutable; use Php\Pie\File\BinaryFile; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Symfony\Component\Console\Output\OutputInterface; @@ -31,7 +30,7 @@ public static function factory( ): self { return new VerifyPieReleaseUsingAttestation( new GithubCliAttestationVerification(new ExecutableFinder()), - new FallbackVerificationUsingOpenSsl(FallbackVerificationUsingOpenSsl::TRUSTED_ROOT_FILE_PATH, new DateTimeImmutable(), $githubApiBaseUrl, $httpDownloader, $authHelper), + new FallbackVerificationUsingOpenSsl(FallbackVerificationUsingOpenSsl::TRUSTED_ROOT_FILE_PATH, $githubApiBaseUrl, $httpDownloader, $authHelper), ); } diff --git a/test/unit/SelfManage/Verify/AttestationTest.php b/test/unit/SelfManage/Verify/AttestationTest.php index 3435bbd2..08b81dc3 100644 --- a/test/unit/SelfManage/Verify/AttestationTest.php +++ b/test/unit/SelfManage/Verify/AttestationTest.php @@ -43,7 +43,7 @@ public function testFromAttestationWithValidBundle(): void } /** - * @return array + * @return list>> * * @psalm-suppress PossiblyUnusedMethod https://github.com/psalm/psalm-plugin-phpunit/issues/131 */ diff --git a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php index 817a1a96..d286463f 100644 --- a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php +++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php @@ -8,8 +8,6 @@ use Composer\Util\AuthHelper; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; -use DateTimeImmutable; -use DateTimeZone; use Php\Pie\File\BinaryFile; use Php\Pie\File\BinaryFileFailedVerification; use Php\Pie\SelfManage\Update\ReleaseMetadata; @@ -63,11 +61,9 @@ public function setUp(): void $this->authHelper = $this->createMock(AuthHelper::class); $this->output = new BufferedOutput(); - $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $this->trustedRootFilePath = tempnam(sys_get_temp_dir(), 'pie_test_trusted_root_file_path'); - $this->verifier = new FallbackVerificationUsingOpenSsl($this->trustedRootFilePath, $now, self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper); + $this->verifier = new FallbackVerificationUsingOpenSsl($this->trustedRootFilePath, self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper); } /** @return array{0: string, 1: string} */ From 69e34e1904e5f8f184e51e310e2f3a37c100771c Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 2 Apr 2025 19:57:40 +0100 Subject: [PATCH 15/15] Split out and improve attestation verification from PR discussion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Düsterhus --- .../Verify/FailedToVerifyRelease.php | 11 +++ .../FallbackVerificationUsingOpenSsl.php | 85 +++++++++++-------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 0cf31dd7..3c8fd932 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -12,6 +12,7 @@ use function implode; use function is_array; use function sprintf; +use function strlen; use function trim; class FailedToVerifyRelease extends RuntimeException @@ -57,6 +58,16 @@ public static function fromNoIssuerCertificateInTrustedRoot(array|string $issuer )); } + public static function fromInvalidDerEncodedStringLength(string $derEncodedString, int $expectedLength): self + { + return new self(sprintf( + 'DER encoded string length of "%s" was wrong; expected %d characters, was actually %d characters', + $derEncodedString, + $expectedLength, + strlen($derEncodedString), + )); + } + public static function fromMismatchingExtensionValues(string $extension, string $expected, string $actual): self { return new self(sprintf( diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index 09fe98ca..d5d7bffc 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -76,8 +76,11 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam * - https://docs.sigstore.dev/logging/verify-release/ * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol */ - $this->assertCertificateClaims($attestation); - $output->writeln('#' . $attestationIndex . ': Certificate facts verified.', OutputInterface::VERBOSITY_VERBOSE); + $this->assertCertificateSignedByTrustedRoot($attestation); + $output->writeln('#' . $attestationIndex . ': Certificate was signed by a trusted root.', OutputInterface::VERBOSITY_VERBOSE); + + $this->assertCertificateExtensionClaims($attestation); + $output->writeln('#' . $attestationIndex . ': Certificate extension claims match.', OutputInterface::VERBOSITY_VERBOSE); $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation); $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE); @@ -89,39 +92,9 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam $output->writeln('✅ Verified the new PIE version (using fallback verification)'); } - private function assertCertificateClaims(Attestation $attestation): void + private function assertCertificateSignedByTrustedRoot(Attestation $attestation): void { $attestationCertificateInfo = openssl_x509_parse($attestation->certificate); - Assert::isArray($attestationCertificateInfo['extensions']); - - /** - * See {@link https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#136141572641--fulcio} for details - * on the Fulcio extension keys; note the values are DER-encoded strings; the ASN.1 tag is UTF8String (0x0C). - * - * First up, check the extension values are what we expect; these are hard-coded, as we don't expect them - * to change unless the namespace/repo name change, etc. - */ - foreach (self::ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES as $extension => $expectedValue) { - Assert::keyExists($attestationCertificateInfo['extensions'], $extension); - Assert::stringNotEmpty($attestationCertificateInfo['extensions'][$extension]); - $actualValue = $attestationCertificateInfo['extensions'][$extension]; - - // First character (the ASN.1 tag) is expected to be UTF8String (0x0C) - if (ord($actualValue[0]) !== 0x0C) { - throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $actualValue); - } - - /** - * Second character is expected to be the length of the actual value - * as long as they are less than 127 bytes (short form) - * - * @link https://www.oss.com/asn1/resources/asn1-made-simple/asn1-quick-reference/basic-encoding-rules.html#Lengths - */ - $derDecodedValue = substr($actualValue, 2, ord($actualValue[1])); - if ($derDecodedValue !== $expectedValue) { - throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $derDecodedValue); - } - } // @todo process in place to make sure this gets updated frequently enough: gh attestation trusted-root > resources/trusted-root.jsonl $trustedRootJsonLines = explode("\n", trim(file_get_contents($this->trustedRootFilePath))); @@ -141,7 +114,11 @@ private function assertCertificateClaims(Attestation $attestation): void $decoded = json_decode($jsonLine, true); // No certificate authorities defined in this JSON line, skip it... - if (! is_array($decoded) || ! array_key_exists('certificateAuthorities', $decoded)) { + if ( + ! is_array($decoded) + || ! array_key_exists('certificateAuthorities', $decoded) + || ! is_array($decoded['certificateAuthorities']) + ) { continue; } @@ -208,6 +185,46 @@ private function assertCertificateClaims(Attestation $attestation): void throw FailedToVerifyRelease::fromNoIssuerCertificateInTrustedRoot($attestationCertificateInfo['issuer']); } + private function assertCertificateExtensionClaims(Attestation $attestation): void + { + $attestationCertificateInfo = openssl_x509_parse($attestation->certificate); + Assert::isArray($attestationCertificateInfo['extensions']); + + /** + * See {@link https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#136141572641--fulcio} for details + * on the Fulcio extension keys; note the values are DER-encoded strings; the ASN.1 tag is UTF8String (0x0C). + * + * Check the extension values are what we expect; these are hard-coded, as we don't expect them + * to change unless the namespace/repo name change, etc. + */ + foreach (self::ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES as $extension => $expectedValue) { + Assert::keyExists($attestationCertificateInfo['extensions'], $extension); + Assert::stringNotEmpty($attestationCertificateInfo['extensions'][$extension]); + $actualValue = $attestationCertificateInfo['extensions'][$extension]; + + // First character (the ASN.1 tag) is expected to be UTF8String (0x0C) + if (ord($actualValue[0]) !== 0x0C) { + throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $actualValue); + } + + /** + * Second character is expected to be the length of the actual value + * as long as they are less than 127 bytes (short form) + * + * @link https://www.oss.com/asn1/resources/asn1-made-simple/asn1-quick-reference/basic-encoding-rules.html#Lengths + */ + $expectedValueLength = ord($actualValue[1]); + if (strlen($actualValue) !== 2 + $expectedValueLength) { + throw FailedToVerifyRelease::fromInvalidDerEncodedStringLength($actualValue, 2 + $expectedValueLength); + } + + $derDecodedValue = substr($actualValue, 2, $expectedValueLength); + if ($derDecodedValue !== $expectedValue) { + throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $derDecodedValue); + } + } + } + private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void { if (! extension_loaded('openssl')) {