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/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/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php new file mode 100644 index 00000000..4faf1e50 --- /dev/null +++ b/src/Command/SelfUpdateCommand.php @@ -0,0 +1,173 @@ +addOption( + self::OPTION_NIGHTLY_UPDATE, + null, + InputOption::VALUE_NONE, + 'Update to the latest nightly version.', + ); + } + + 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( + $this->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 = VerifyPieReleaseUsingAttestation::factory($this->githubApiBaseUrl, $httpDownloader, $authHelper); + + 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, + ); + } + + $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease); + + $output->writeln( + sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum), + OutputInterface::VERBOSITY_VERBOSE, + ); + + try { + $verifyPiePhar->verify($latestRelease, $pharFilename, $output); + } 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; + } + + $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/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/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/Update/FetchPieRelease.php b/src/SelfManage/Update/FetchPieRelease.php new file mode 100644 index 00000000..ef02be0e --- /dev/null +++ b/src/SelfManage/Update/FetchPieRelease.php @@ -0,0 +1,16 @@ +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 @@ + $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/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php new file mode 100644 index 00000000..3c8fd932 --- /dev/null +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -0,0 +1,97 @@ +tag, + $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, + )); + } + + /** @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 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( + '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.'); + } + + 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 new file mode 100644 index 00000000..d5d7bffc --- /dev/null +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -0,0 +1,314 @@ + '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 string $githubApiBaseUrl, + private readonly HttpDownloader $httpDownloader, + private readonly AuthHelper $authHelper, + ) { + } + + public function verify(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->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); + + $this->verifyDsseEnvelopeSignature($releaseMetadata, $attestationIndex, $attestation); + $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE); + } + + $output->writeln('✅ Verified the new PIE version (using fallback verification)'); + } + + private function assertCertificateSignedByTrustedRoot(Attestation $attestation): void + { + $attestationCertificateInfo = openssl_x509_parse($attestation->certificate); + + // @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) + || ! is_array($decoded['certificateAuthorities']) + ) { + 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; + } + + // 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); + + // 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; + } + + // 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 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')) { + 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..901246d5 --- /dev/null +++ b/src/SelfManage/Verify/GithubCliAttestationVerification.php @@ -0,0 +1,52 @@ +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); + } catch (GithubCliNotAvailable $githubCliNotAvailable) { + $output->writeln($githubCliNotAvailable->getMessage(), OutputInterface::VERBOSITY_VERBOSE); + + if (! extension_loaded('openssl')) { + throw FailedToVerifyRelease::fromNoOpenssl(); + } + + $this->fallbackVerification->verify($releaseMetadata, $pharFilename, $output); + } + } +} 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; } } 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 new file mode 100755 index 00000000..d0f8e4d9 --- /dev/null +++ b/test/assets/fake-gh-cli/happy.sh @@ -0,0 +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 new file mode 100755 index 00000000..df58d24d --- /dev/null +++ b/test/assets/fake-gh-cli/unhappy.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "Pretending to be gh cli - unhappy path" +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..08b81dc3 --- /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 list>> + * + * @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..d286463f --- /dev/null +++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php @@ -0,0 +1,279 @@ +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->trustedRootFilePath = tempnam(sys_get_temp_dir(), 'pie_test_trusted_root_file_path'); + + $this->verifier = new FallbackVerificationUsingOpenSsl($this->trustedRootFilePath, 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, ['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('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..ad9863d4 --- /dev/null +++ b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php @@ -0,0 +1,71 @@ +executableFinder = $this->createMock(ExecutableFinder::class); + $this->output = new BufferedOutput(); + + $this->verifier = new GithubCliAttestationVerification($this->executableFinder); + } + + public function testPassingVerification(): void + { + $this->executableFinder + ->method('find') + ->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); + + 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(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); + } +}