├── .github └── workflows │ ├── continuous-integration.yml │ └── release-on-milestone-closed-triggering-release-event.yml ├── .gitignore ├── .laminas-ci.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── infection.json.dist ├── phpcs.xml.dist ├── phpunit.xml.dist ├── psalm.xml.dist ├── renovate.json ├── src ├── CheckerInterface.php ├── Encoder │ ├── Base64Encoder.php │ ├── EncoderInterface.php │ ├── HmacEncoder.php │ └── Sha1SumEncoder.php ├── FileContentChecker.php ├── FileContentSigner.php └── SignerInterface.php └── test ├── fixture ├── UserClass.php └── UserClassSignedByFileContent.php └── unit └── src ├── Encoder ├── Base64EncoderTest.php ├── HmacEncoderTest.php └── Sha1SumEncoderTest.php ├── FileContentCheckerTest.php └── FileContentSignerTest.php /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/laminas/laminas-continuous-integration-action 2 | # Generates a job matrix based on current dependencies and supported version 3 | # ranges, then runs all those jobs 4 | name: "Continuous Integration" 5 | 6 | on: 7 | pull_request: 8 | push: 9 | 10 | jobs: 11 | matrix: 12 | name: Generate job matrix 13 | runs-on: ubuntu-latest 14 | outputs: 15 | matrix: ${{ steps.matrix.outputs.matrix }} 16 | steps: 17 | - name: Gather CI configuration 18 | id: matrix 19 | uses: laminas/laminas-ci-matrix-action@1.29.0 20 | 21 | qa: 22 | name: QA Checks 23 | needs: [ matrix ] 24 | runs-on: ${{ matrix.operatingSystem }} 25 | strategy: 26 | fail-fast: false 27 | matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }} 28 | steps: 29 | - name: ${{ matrix.name }} 30 | uses: laminas/laminas-continuous-integration-action@1.41.0 31 | env: 32 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 33 | "INFECTION_DASHBOARD_API_KEY": ${{ secrets.INFECTION_DASHBOARD_API_KEY }} 34 | "STRYKER_DASHBOARD_API_KEY": ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 35 | with: 36 | job: ${{ matrix.job }} 37 | -------------------------------------------------------------------------------- /.github/workflows/release-on-milestone-closed-triggering-release-event.yml: -------------------------------------------------------------------------------- 1 | # Alternate workflow example. 2 | # This one is identical to the one in release-on-milestone.yml, with one change: 3 | # the Release step uses the ORGANIZATION_ADMIN_TOKEN instead, to allow it to 4 | # trigger a release workflow event. This is useful if you have other actions 5 | # that intercept that event. 6 | 7 | name: "Automatic Releases" 8 | 9 | on: 10 | milestone: 11 | types: 12 | - "closed" 13 | 14 | jobs: 15 | release: 16 | name: "GIT tag, release & create merge-up PR" 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: "Checkout" 21 | uses: "actions/checkout@v4" 22 | 23 | - name: "Release" 24 | uses: "laminas/automatic-releases@v1" 25 | with: 26 | command-name: "laminas:automatic-releases:release" 27 | env: 28 | "GITHUB_TOKEN": ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} 29 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 30 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 31 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 32 | 33 | - name: "Create Merge-Up Pull Request" 34 | uses: "laminas/automatic-releases@v1" 35 | with: 36 | command-name: "laminas:automatic-releases:create-merge-up-pull-request" 37 | env: 38 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 39 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 40 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 41 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 42 | 43 | - name: "Create and/or Switch to new Release Branch" 44 | uses: "laminas/automatic-releases@v1" 45 | with: 46 | command-name: "laminas:automatic-releases:switch-default-branch-to-next-minor" 47 | env: 48 | "GITHUB_TOKEN": ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} 49 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 50 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 51 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 52 | 53 | - name: "Bump Changelog Version On Originating Release Branch" 54 | uses: "laminas/automatic-releases@v1" 55 | with: 56 | command-name: "laminas:automatic-releases:bump-changelog" 57 | env: 58 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 59 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 60 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 61 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 62 | 63 | - name: "Create new milestones" 64 | uses: "laminas/automatic-releases@v1" 65 | with: 66 | command-name: "laminas:automatic-releases:create-milestones" 67 | env: 68 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 69 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 70 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 71 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | phpunit.xml 2 | infection-log.txt 3 | .idea 4 | vendor 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /.laminas-ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": [ 3 | "pcov" 4 | ], 5 | "ini": [ 6 | "memory_limit=-1" 7 | ], 8 | "exclude": [ 9 | {"name": "Infection [8.2, locked]"} 10 | ], 11 | "additional_checks": [ 12 | { 13 | "name": "Infection (with PCOV)", 14 | "job": { 15 | "php": "8.4", 16 | "dependencies": "locked", 17 | "command": "./vendor/bin/roave-infection-static-analysis-plugin" 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | # Contributing 6 | 7 | * Coding standard for the project is [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) 8 | * The project aims to follow most [object calisthenics](http://www.slideshare.net/guilhermeblanco/object-calisthenics-applied-to-php) 9 | * Any contribution must provide tests for additional introduced conditions 10 | * Any un-confirmed issue needs a failing test case before being accepted 11 | * Pull requests must be sent from a new hotfix/feature branch, not from `master`. 12 | 13 | ## Installation 14 | 15 | To install the project and run the tests, you need to clone it first: 16 | 17 | ```sh 18 | $ git clone git://github.com/Roave/Signature.git 19 | ``` 20 | 21 | You will then need to run a [Composer](https://getcomposer.org/) installation: 22 | 23 | ```sh 24 | $ cd Signature 25 | $ curl -s https://getcomposer.org/installer | php 26 | $ php composer.phar update 27 | ``` 28 | 29 | ## Testing 30 | 31 | The PHPUnit version to be used is the one installed as a dev- dependency via composer: 32 | 33 | ```sh 34 | $ vendor/bin/phpunit 35 | ``` 36 | 37 | Please ensure all new features or conditions are covered by unit tests. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Roave, LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :black_nib: Roave\Signature 2 | 3 | Sign and validate signed files made easy. 4 | 5 | **Note: this is not a cryptographic signing library.** 6 | 7 | ## Installation 8 | 9 | The suggested installation method is via [composer](https://getcomposer.org/): 10 | 11 | ```bash 12 | $ composer require roave/signature 13 | ``` 14 | 15 | ## Usage examples 16 | 17 | ### Signing a file 18 | 19 | ```php 20 | // Creating a signer 21 | $signer = new \Roave\Signature\FileContentSigner( 22 | new \Roave\Signature\Encoder\Sha1SumEncoder() 23 | ); 24 | 25 | // It'll give you a signature to the provided code content 26 | $signature = $signer->sign(file_get_contents('/var/tmp/file.php')); 27 | ``` 28 | 29 | ### Validating a signed file 30 | 31 | ```php 32 | // Creating a signer checker 33 | $signer = new \Roave\Signature\FileContentChecker( 34 | new \Roave\Signature\Encoder\Sha1SumEncoder() 35 | ); 36 | 37 | // It'll validate the signature on file content 38 | $signer->check(file_get_contents('/var/tmp/signed-file.php')); 39 | ``` 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roave/signature", 3 | "description": "Sign and verify stuff", 4 | "license": "MIT", 5 | "require": { 6 | "php": "~8.2.0 || ~8.3.0 || ~8.4.0" 7 | }, 8 | "require-dev": { 9 | "phpunit/phpunit": "^11.5.7", 10 | "roave/infection-static-analysis-plugin": "^1.36.0", 11 | "doctrine/coding-standard": "^12.0.0", 12 | "vimeo/psalm": "^6.6.1", 13 | "psalm/plugin-phpunit": "^0.19.2" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Roave\\Signature\\": "src" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Roave\\SignatureTest\\": "test/unit/src", 23 | "Roave\\SignatureTestFixture\\": "test/fixture" 24 | } 25 | }, 26 | "minimum-stability": "dev", 27 | "prefer-stable": true, 28 | "config": { 29 | "allow-plugins": { 30 | "dealerdirect/phpcodesniffer-composer-installer": true, 31 | "infection/extension-installer": true 32 | }, 33 | "platform": { 34 | "php": "8.2.99" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "php://stderr", 10 | "stryker": { 11 | "report": "/^\\d\\.\\d\\.x$/" 12 | } 13 | }, 14 | "minMsi": 100, 15 | "minCoveredMsi": 100 16 | } -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./src 18 | ./test/unit 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | ./test/unit 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Ocramius/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/CheckerInterface.php: -------------------------------------------------------------------------------- 1 | encode($codeWithoutSignature), $signature); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Encoder/EncoderInterface.php: -------------------------------------------------------------------------------- 1 | hmacKey = $hmacKey; 17 | } 18 | 19 | public function encode(string $codeWithoutSignature): string 20 | { 21 | return hash_hmac('sha256', $codeWithoutSignature, $this->hmacKey); 22 | } 23 | 24 | public function verify(string $codeWithoutSignature, string $signature): bool 25 | { 26 | return hash_equals($this->encode($codeWithoutSignature), $signature); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Encoder/Sha1SumEncoder.php: -------------------------------------------------------------------------------- 1 | encode($codeWithoutSignature), $signature); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/FileContentChecker.php: -------------------------------------------------------------------------------- 1 | encoder = $encoder; 24 | } 25 | 26 | public function check(string $phpCode): bool 27 | { 28 | if (! preg_match('{Roave/Signature:\s+([a-zA-Z0-9\/=]+)}', $phpCode, $matches)) { 29 | return false; 30 | } 31 | 32 | return $this->encoder->verify($this->stripCodeSignature($phpCode), $matches[1]); 33 | } 34 | 35 | private function stripCodeSignature(string $phpCode): string 36 | { 37 | $replaced = preg_replace('{[\/\*\s]+Roave/Signature:\s+([a-zA-Z0-9\/\*\/ =]+)}', '', $phpCode); 38 | 39 | assert(is_string($replaced)); 40 | 41 | return $replaced; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/FileContentSigner.php: -------------------------------------------------------------------------------- 1 | encoder = $encoder; 19 | } 20 | 21 | public function sign(string $phpCode): string 22 | { 23 | return 'Roave/Signature: ' . $this->encoder->encode($phpCode); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/SignerInterface.php: -------------------------------------------------------------------------------- 1 | encode(' ')); 22 | self::assertSame('PD9waHA=', $encoder->encode('verify($value, base64_encode($value))); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/unit/src/Encoder/HmacEncoderTest.php: -------------------------------------------------------------------------------- 1 | encode($value), 25 | ); 26 | } 27 | 28 | public function testVerify(): void 29 | { 30 | $hmacKey = random_bytes(64); 31 | $value = uniqid('values', true); 32 | self::assertTrue((new HmacEncoder($hmacKey))->verify($value, hash_hmac('sha256', $value, $hmacKey))); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/unit/src/Encoder/Sha1SumEncoderTest.php: -------------------------------------------------------------------------------- 1 | encode($value)); 21 | } 22 | 23 | public function testVerify(): void 24 | { 25 | $value = uniqid('values', true); 26 | self::assertTrue((new Sha1SumEncoder())->verify($value, sha1($value))); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/unit/src/FileContentCheckerTest.php: -------------------------------------------------------------------------------- 1 | encoder = $this->createMock(EncoderInterface::class); 30 | } 31 | 32 | public function testShouldCheckClassFileContent(): void 33 | { 34 | $classFilePath = __DIR__ . '/../../fixture/UserClassSignedByFileContent.php'; 35 | 36 | self::assertFileExists($classFilePath); 37 | 38 | $checker = new FileContentChecker(new Base64Encoder()); 39 | 40 | $checker->check(self::readFile($classFilePath)); 41 | } 42 | 43 | public function testShouldReturnFalseIfSignatureDoesNotMatch(): void 44 | { 45 | $classFilePath = __DIR__ . '/../../fixture/UserClassSignedByFileContent.php'; 46 | 47 | self::assertFileExists($classFilePath); 48 | 49 | $expectedSignature = 'YToxOntpOjA7czoxNDE6Ijw/cGhwCgpuYW1lc3BhY2UgU2lnbmF0dXJlVGVzdEZpeHR1cmU7' . 50 | 'CgpjbGFzcyBVc2VyQ2xhc3NTaWduZWRCeUZpbGVDb250ZW50CnsKICAgIHB1YmxpYyAkbmFtZTsKCiAgICBwcm90ZW' . 51 | 'N0ZWQgJHN1cm5hbWU7CgogICAgcHJpdmF0ZSAkYWdlOwp9CiI7fQ=='; 52 | 53 | $this->encoder->expects(self::once())->method('verify')->with( 54 | str_replace( 55 | '/** Roave/Signature: ' . $expectedSignature . ' */' . "\n", 56 | '', 57 | self::readFile($classFilePath), 58 | ), 59 | $expectedSignature, 60 | ); 61 | 62 | $checker = new FileContentChecker($this->encoder); 63 | 64 | self::assertFalse($checker->check(self::readFile($classFilePath))); 65 | } 66 | 67 | public function testShouldReturnFalseIfClassIsNotSigned(): void 68 | { 69 | $classFilePath = __DIR__ . '/../../fixture/UserClass.php'; 70 | 71 | self::assertFileExists($classFilePath); 72 | 73 | $checker = new FileContentChecker($this->encoder); 74 | 75 | self::assertFalse($checker->check(self::readFile($classFilePath))); 76 | } 77 | 78 | private static function readFile(string $path): string 79 | { 80 | $contents = file_get_contents($path); 81 | 82 | assert(is_string($contents)); 83 | 84 | return $contents; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/unit/src/FileContentSignerTest.php: -------------------------------------------------------------------------------- 1 | */ 17 | public static function signProvider(): array 18 | { 19 | return [ 20 | ['Roave/Signature: PD9waHA=', ''], 23 | ['Roave/Signature: cGxhaW4gdGV4dA==', 'plain text'], 24 | ]; 25 | } 26 | 27 | /** @dataProvider signProvider */ 28 | #[DataProvider('signProvider')] 29 | public function testSign(string $expected, string $inputString): void 30 | { 31 | $signer = new FileContentSigner(new Base64Encoder()); 32 | 33 | self::assertSame($expected, $signer->sign($inputString)); 34 | } 35 | } 36 | --------------------------------------------------------------------------------