1
0
mirror of https://github.com/php/pie.git synced 2026-03-24 07:22:17 +01:00
Files
archived-pie/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php

285 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Php\PieUnitTest\SelfManage\Verify;
use Composer\Downloader\TransportException;
use Composer\IO\BufferIO;
use Composer\Util\AuthHelper;
use Composer\Util\Http\Response;
use Composer\Util\HttpDownloader;
use Php\Pie\File\BinaryFile;
use Php\Pie\SelfManage\Update\ReleaseMetadata;
use Php\Pie\SelfManage\Verify\FailedToVerifyRelease;
use Php\Pie\SelfManage\Verify\FallbackVerificationUsingOpenSsl;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use ThePhpFoundation\Attestation\Verification\VerifyAttestationWithOpenSsl;
use function assert;
use function base64_encode;
use function extension_loaded;
use function file_put_contents;
use function is_string;
use function json_encode;
use function openssl_csr_new;
use function openssl_csr_sign;
use function openssl_pkey_new;
use function openssl_sign;
use function openssl_x509_export;
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;
#[CoversClass(FallbackVerificationUsingOpenSsl::class)]
final class FallbackVerificationUsingOpenSslTest extends TestCase
{
private const TEST_GITHUB_URL = 'http://test-github-url.localhost';
private const DSSE_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
private ReleaseMetadata $release;
private BinaryFile $downloadedPhar;
private HttpDownloader&MockObject $httpDownloader;
private AuthHelper&MockObject $authHelper;
private BufferIO $io;
private FallbackVerificationUsingOpenSsl $verifier;
/** @var non-empty-string */
private string $trustedRootFilePath;
public function setUp(): void
{
parent::setUp();
$this->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->io = new BufferIO();
$trustedRootFilePath = tempnam(sys_get_temp_dir(), 'pie_test_trusted_root_file_path');
assert(is_string($trustedRootFilePath));
$this->trustedRootFilePath = $trustedRootFilePath;
$this->verifier = new FallbackVerificationUsingOpenSsl(new VerifyAttestationWithOpenSsl($this->trustedRootFilePath, self::TEST_GITHUB_URL, $this->httpDownloader));
}
/** @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, ['config' => $tempOpensslConfig]);
$certificate = openssl_csr_sign($csr, $caCert, $caPrivateKey, 1, [
'config' => $tempOpensslConfig,
'x509_extensions' => 'v3_req',
]);
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('addAuthenticationOptions')
->willReturn(['http' => ['header' => ['Authorization: Bearer fake-token']]]);
$this->httpDownloader->expects(self::once())
->method('get')
->with(
$url,
[
'retry-auth-failure' => true,
'http' => [
'method' => 'GET',
'header' => [],
],
],
)
->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->io);
self::assertStringContainsString('Verified the new PIE version (using fallback verification)', $this->io->getOutput());
}
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(FailedToVerifyRelease::class);
$this->verifier->verify($this->release, $this->downloadedPhar, $this->io);
}
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->io);
}
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('addAuthenticationOptions')
->willReturn(['http' => ['header' => ['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->io);
}
}