├── .editorconfig ├── .github └── workflows │ └── php.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpdoc.dist.xml ├── phpunit.xml ├── sh ├── generate_csig.sh ├── sign_intermediate.sh └── validate_csig.sh ├── src ├── ChecksumList.php ├── FailedCheckumFilter.php ├── Verifier.php ├── VerifierB64Data.php ├── VerifierException.php └── VerifierFileChecksum.php └── tests ├── SignifyTest.php └── fixtures ├── artifact1.php ├── artifact1.php.sig ├── checksumlist-compromised.sig ├── checksumlist.pub ├── checksumlist.sec ├── checksumlist.sig ├── embed.pub ├── embed.sec ├── embed.sig ├── embed.txt ├── intermediate ├── checksumlist.c ├── checksumlist.csig ├── int.pub ├── int.sec ├── payload.zip ├── root.pub └── root.sec ├── multiple-files ├── a.txt ├── b.txt ├── c.txt ├── d.txt ├── module.csig └── root.pub ├── payload-compromised.zip ├── payload.zip ├── test1-php-signify.pub ├── test1-php-signify.sec ├── test2-php-signify.pub └── test2-php-signify.sec /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | charset = utf-8 10 | tab_width = 4 11 | 12 | [*.{php,xml}] 13 | indent_size = 4 14 | indent_style = space 15 | 16 | [*.yml] 17 | indent_size = 2 18 | indent_style = space 19 | 20 | [*.md] 21 | indent_style = space 22 | max_line_length = 80 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | # operating-system: [windows-latest] 12 | # Windows fails testing on gitlab. Runs fine on local windows. 13 | operating-system: [ubuntu-latest, macos-latest] 14 | php-versions: ['7.4', '7.3', '7.2', '7.1', '7.0', '5.6', '5.5', '5.4', '5.3'] 15 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-versions }} 24 | extensions: mbstring 25 | 26 | - name: Setup problem matchers for PHP 27 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 28 | 29 | - name: Setup problem matchers for PHPUnit 30 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 31 | 32 | - name: Validate composer.json and composer.lock 33 | run: composer validate 34 | 35 | - name: Install dependencies 36 | run: composer install --prefer-dist --no-progress --no-suggest 37 | 38 | - name: Get composer cache directory 39 | id: composer-cache 40 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 41 | 42 | - name: Cache dependencies 43 | uses: actions/cache@v1 44 | with: 45 | path: ${{ steps.composer-cache.outputs.dir }} 46 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 47 | restore-keys: ${{ runner.os }}-composer- 48 | 49 | - name: Run test suite 50 | run: composer run-script test 51 | 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | .DS_Store 4 | .phpunit.result.cache 5 | data/output 6 | docs 7 | .phpdoc.dist.xml 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Strauss, Peter Wolanin, Mike Baynton, Neil Drumm 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Signify 2 | 3 | PHP library for verification of BSD Signify signature files, plus PHP and shell 4 | implementations of verifying extended CSIG signature files. 5 | 6 | ![PHP Composer](https://github.com/drupal/php-signify/workflows/PHP%20Composer/badge.svg) 7 | 8 | ## Use Case 9 | 10 | Drupal's auto-update and core validation work depends on access to trusted 11 | metadata and code assets. Because Drupal is deployed globally to diverse 12 | environments, our implementation should support public and private mirroring 13 | of data as well as validation by widely varying PHP releases and web host 14 | configurations. Hosts with outdated root certificates, insecure ciphers, or 15 | outdated TLS version support should not undermine access to these new Drupal 16 | features. 17 | 18 | ## Why Signify? 19 | 20 | Signify is a defacto standard created by OpenBSD to validate assets using modern 21 | cryptography. The cryptographic foundations are entirely available in PHP's 22 | Sodium APIs and are available for legacy PHP versions through sodium_compat. 23 | Therefore, it is possible to implement Signify on every PHP version currently 24 | supported by current Drupal releases. 25 | 26 | Signify allows Drupal to anchor trust to on-disk keys shipped with the initial 27 | installation. Combined with the broad supportability of Signify's cryptography 28 | in PHP releases, we can establish trust on quite outdated infrastructure. This 29 | fits with our goal of building update features that support the long tail of 30 | installations. 31 | 32 | ## Extending Signify 33 | 34 | OpenBSD's build and release model differs from Drupal's. 35 | 36 | First, Drupal maintains an extensive hosted build infrastructure that covers 37 | everything from core to arbitrary community projects. Maintaining the 38 | availability of these systems creates challenges for protecting the secrets, and 39 | we desire keeping the root of trust offline. 40 | 41 | Second, our release model isn't a compelling foundation for key rotation. Our 42 | major releases happen at too long of an interval for rotation. Our minor 43 | releases are more frequent, but users expect cross-compatibility (and requisite 44 | signature validation to work) across arbitrary mixes of minor releases and 45 | module builds. Site owners may also neglect their site across quite a few minor 46 | releases, making OpenBSD's model of shipping future release keys insufficient 47 | for maintaining continuity (without, say, shipping the next 10 keys and having 48 | little recourse if one of the corresponding private keys leaks). 49 | 50 | Taking a little inspiration from X.509 -- with an emphasis on little -- we've 51 | extended Signify to support chained signatures. We call this format CSIG. 52 | 53 | ## Chaining with CSIG 54 | 55 | Our goal with CSIG is to support an offline root protected by an HSM. That HSM 56 | setup should periodically produce expiring signatures against the next public 57 | key for use within the build infrastructure. 58 | 59 | We can accomplish this using the building blocks of Signify, but we'd like to 60 | pack the pieces into a single file for ease of distribution and validation. 61 | 62 | Initial setup of CSIG infrastructure: 63 | 64 | 1. Generate a keypair on the HSM. 65 | 1. Export the public key and package in Signify's format. 66 | 1. Bundle this public key with Drupal releases. 67 | 68 | Periodic key rotation on the build infrastructure: 69 | 70 | 1. Generate a keypair on the build infrastructure. This can happen automatically but not be used until promoted into use by the final step. 71 | 1. Use the HSM to sign and embed a message containing an expiration date and the build infrastructure's public key. 72 | 1. Upload that signed message to the build infrastructure. This functions as an intermediate certificate. 73 | 74 | Generating a CSIG for a build: 75 | 76 | 1. Generate a tagged (BSD-style) sha512sum of the built asset. 77 | 1. Sign and embed the generated checksum list (to use the OpenBSD term) using the build infrastructure's secret key. 78 | 1. Prepend the intermediate certificate. 79 | 80 | Validating an asset using a CSIG: 81 | 82 | 1. Extract and validate the intermediate certificate against the root public key. 83 | 1. Check that the intermediate certificate remains valid (today in UTC is not after the valid-through date). 84 | 1. Extract the intermediate public key from the now-validated intermediate certificate. 85 | 1. Extract the signed checksum list. 86 | 1. Validate the signed checksum list against the intermediate public key. 87 | 88 | ### Format of a CSIG 89 | 90 | Bold lines are annotations that do not occur in the CSIG. 91 | 92 | * **Intermediate key and its expiration** 93 | * Untrusted Comment (line #1) 94 | * Base64-Encoded Signature by Root Secret Key (line #2) 95 | * **Message is an expiring public key, or xpub** 96 | * Valid Through Date in UTC in YYYY-MM-DD Format (line #3) 97 | * **Build Infrastructure Public Key in Signify Format** 98 | * "Untrusted" Comment (line #4) 99 | * Base64-Encoded Public Key (Build Infrastructure Key) (line #5) 100 | * **Message or Checksum List signed with key on lines 4-5** 101 | * Untrusted Comment (line #6) 102 | * Base64-Encoded Signature by Build Infrastructure Key (line #7) 103 | * **Message or Checksum List** 104 | * Message or Checksum List Entries (lines 8+) 105 | 106 | If there is only one checksum list entry, the result should be nine lines, 107 | including a blank line at the end. Each additional checksum list entry adds one 108 | line. 109 | 110 | A possible point of confusion is that line 4 begins with `untrusted comment`, 111 | but in fact it is part of the overall message signed by the Root Secret Key. 112 | This is done to allow easy usage of the Build Infrastructure Public Key in 113 | Signify format - it must necessarily begin with the bytes `untrusted comment`. 114 | #### Example CSIG File 115 | 116 | untrusted comment: verify with root.pub 117 | RWT/sFZ5HK1Dq7ML8TDNwKQGd40VZMEUXyC9bdI37YscjwO9+SZoyqmRSTWbJoQeGanRYpcBY4gxvKiWDjkwrVIqAksv0g08cwI= 118 | 2019-09-10 119 | untrusted comment: build infrastructure key generated 2019-08-10 120 | RWQ5TWYMFcc7gi3kSGCZrFm0rR4O0NnLvH603c4vMvHEvovmzzpgW8eC 121 | untrusted comment: verify with build-infrastructure-20190810.pub 122 | RWQ5TWYMFcc7gpE7lJZ2dbMK/x9iUPD08lfjGQtha9n4qIW/h7kQBjBcaYNNNKzQpJY3Xjgttm+TkxqlQNpz9sT+48mgC+xjCgY= 123 | SHA512 (module.zip) = f53bef3e52bcbd7d822190a7458706ff5a4b10066a52e843ef10779b55f2b6ad16c928b42def63b2204af1e7c0baaf8d9ab1d172e2b78174626f42da90a15904 124 | 125 | #### Example CLI Creation of a CSIG File 126 | 127 | For convenience, this example uses the `-n` option to disable passphrases. 128 | 129 | $ signify -G -n -p root.pub -s root.sec 130 | $ signify -G -n -p intermediate.pub -s intermediate.sec 131 | $ date --utc --iso-8601 --date="+30 days" > expiration 132 | $ cat expiration intermediate.pub | signify -S -e -s root.sec -m - -x intermediate.xpub.sig # xpub = expiring public key 133 | $ sha512sum --tag module.zip > module-checksum-list 134 | $ signify -S -e -s intermediate.sec -m module-checksum-list -x module.sig 135 | $ cat intermediate.xpub.sig module.sig > module.csig 136 | 137 | #### Example CLI Validation of CSIG File 138 | 139 | Requisite files: `root.pub` `module.zip` `module.csig` 140 | 141 | $ head --lines=5 module.csig > intermediate.xpub.sig 142 | $ signify -V -e -p root.pub -m intermediate.xpub # Verifies/extracts intermediate.xpub.sig, creates intermediate.xpub 143 | $ head --lines=1 intermediate.xpub # Displays valid-through date in UTC. Should be on or after the current date in UTC. 144 | $ tail --lines=2 intermediate.xpub > intermediate.pub 145 | $ tail --lines=+6 module.csig | signify -C -p intermediate.pub -x - 146 | 147 | ## Running Tests 148 | 149 | sudo dnf install composer 150 | git clone https://github.com/drupalassociation/php-signify 151 | cd php-signify 152 | composer install 153 | vendor/bin/phpunit 154 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drupal/php-signify", 3 | "description": "Signify-compliant signature verification", 4 | "keywords": ["signify", "cryptography", "security"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Drupal Association", 9 | "homepage": "https://www.drupal.org/association" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=5.3.0", 14 | "paragonie/sodium_compat": "^1.10" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^4|^5|^6|^7|^8|^9", 18 | "ext-mbstring": "*", 19 | "symfony/phpunit-bridge": "^2|^3|^4|^5" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Drupal\\Signify\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": {}, 27 | "scripts": { 28 | "test": "phpunit" 29 | }, 30 | "extra": { 31 | "branch-alias": { 32 | "dev-master": "1.0.x-dev" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpdoc.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | data/output 5 | 6 | 7 | docs 8 | 9 | 10 | src 11 | 12 | 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /sh/generate_csig.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | intseckey=$1 4 | introotsig=$2 5 | filetohashandsign=$3 6 | 7 | if [ -z $intseckey ] || [ -z $introotsig ] || [ -z $filetohashandsign ]; then 8 | >&2 echo "USAGE: $0 intermediate_secret_key.sec root_intermediate_signature.sig file_to_hash_and_sign > final.csig" 9 | exit 1 10 | fi 11 | 12 | if [ ! -f "$intseckey" ]; then 13 | >&2 echo "FILE NOT FOUND: $intseckey" 14 | exit 1 15 | fi 16 | 17 | if [ ! -f "$introotsig" ]; then 18 | >&2 echo "FILE NOT FOUND: $introotsig" 19 | exit 1 20 | fi 21 | 22 | if [ ! -f "$filetohashandsign" ]; then 23 | >&2 echo "FILE NOT FOUND: $filetohashandsign" 24 | exit 1 25 | fi 26 | 27 | >&2 echo "Outputting csig for $filetohashandsign" 28 | cat $introotsig 29 | 30 | if which sha512sum 2> /dev/null; then 31 | hash=$(sha512sum --tag $filetohashandsign) 32 | else 33 | # shasum is usually on both BSD and linux systems. 34 | data=$(shasum -a 512 $filetohashandsign) 35 | part1=$(echo "$data" | cut -d ' ' -f1) 36 | part2=$(echo "$data" | cut -d ' ' -f3) 37 | hash="SHA512 ($part2) = $part1" 38 | fi 39 | 40 | echo "$hash" | signify -S -e -s $intseckey -m - -x - 41 | 42 | -------------------------------------------------------------------------------- /sh/sign_intermediate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rootseckey=$1 4 | intpubkey=$2 5 | if [ -z $rootseckey ] || [ -z $intpubkey ]; then 6 | >&2 echo "USAGE: $0 root_secret_key.sec intermediate_public_key.pub > signed.sig" 7 | exit 1 8 | fi 9 | 10 | if [ ! -f "$rootseckey" ]; then 11 | >&2 echo "FILE NOT FOUND: $rootseckey" 12 | exit 1 13 | fi 14 | 15 | if [ ! -f "$intpubkey" ]; then 16 | >&2 echo "FILE NOT FOUND: $intpubkey" 17 | exit 1 18 | fi 19 | 20 | >&2 echo "Outputting signature of intermediate public key $2 with root secret $1" 21 | 22 | msgfile=$(mktemp /tmp/intsigmsg.XXXXXX) 23 | 24 | # Options have to switch on BSD vs. linux 25 | # -u is UTC 26 | # Specify iso8601 format directly. 27 | if date -u --date=+30days 2>/dev/null; then 28 | offset="--date=+30days" 29 | else 30 | offset="-v +30d" 31 | fi 32 | date -u $offset +%Y-%m-%d > $msgfile 33 | cat $intpubkey >> "$msgfile" 34 | signify -S -e -s $rootseckey -m $msgfile -x - 35 | rm -f "$msgfile" 36 | 37 | -------------------------------------------------------------------------------- /sh/validate_csig.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rootpubkey=$1 4 | csigfile=$2 5 | 6 | if [ -z $csigfile ] || [ -z $rootpubkey ]; then 7 | >&2 echo "USAGE: $0 root_public_key.pub combined_siignature_file.csig" 8 | exit 1 9 | fi 10 | 11 | if [ ! -f "$rootpubkey" ]; then 12 | >&2 echo "FILE NOT FOUND: $rootpubkey" 13 | exit 1 14 | fi 15 | 16 | if [ ! -f "$csigfile" ]; then 17 | >&2 echo "FILE NOT FOUND: $csigfile" 18 | exit 1 19 | fi 20 | 21 | >&2 echo "Validating csig $2 using trusted root public key $1" 22 | 23 | 24 | intsig=$(mktemp /tmp/intsigmsg.XXXXXX) 25 | head -n 5 $csigfile > $intsig 26 | 27 | message1=$(mktemp /tmp/intsigmsg.XXXXXX) 28 | signify -V -e -x $intsig -p $rootpubkey -m $message1 29 | validthrough=$(head -n 1 $message1) 30 | 31 | intpubkey=$(mktemp /tmp/intsigmsg.XXXXXX) 32 | tail -n +2 $message1 > $intpubkey 33 | 34 | 35 | today=$(date -u +%Y-%m-%d) 36 | validthrough=$(head -n 1 $message1) 37 | if [[ "$today" > "$validthrough" ]] ; then 38 | >&2 echo "Intermediate key was valid through $validthrough (today is $today in UTC)" 39 | exit 1 40 | fi 41 | 42 | tail -n +6 $csigfile | signify -C -p $intpubkey -x - 43 | -------------------------------------------------------------------------------- /src/ChecksumList.php: -------------------------------------------------------------------------------- 1 | 64, 'SHA512' => 128); 9 | 10 | protected $checksums = array(); 11 | 12 | protected $position = 0; 13 | 14 | public function __construct($checksum_list_raw, $list_is_trusted) 15 | { 16 | $lines = explode("\n", $checksum_list_raw); 17 | foreach ($lines as $line) { 18 | if (trim($line) == '') { 19 | continue; 20 | } 21 | 22 | if (substr($line, 0, 1) === '\\') { 23 | throw new VerifierException('Filenames with problematic characters are not yet supported.'); 24 | } 25 | 26 | $algo = substr($line, 0, strpos($line, ' ')); 27 | if (empty($this->HASH_ALGO_BASE64_LENGTHS[$algo])) { 28 | throw new VerifierException("Algorithm \"$algo\" is unsupported for checksum verification."); 29 | } 30 | 31 | $filename_start = strpos($line, '(') + 1; 32 | $bytes_after_filename = $this->HASH_ALGO_BASE64_LENGTHS[$algo] + 4; 33 | $filename = substr($line, $filename_start, -$bytes_after_filename); 34 | 35 | $this->checksums[] = new VerifierFileChecksum($filename, $algo, substr($line, -$this->HASH_ALGO_BASE64_LENGTHS[$algo]), $list_is_trusted); 36 | } 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function current() { 43 | return $this->checksums[$this->position]; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function next() { 50 | $this->position += 1; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function key() { 57 | return $this->position; 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function valid() { 64 | return isset($this->checksums[$this->position]); 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function rewind() { 71 | $this->position = 0; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function count() { 78 | return count($this->checksums); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/FailedCheckumFilter.php: -------------------------------------------------------------------------------- 1 | workingDirectory = $working_directory; 14 | } 15 | 16 | /** 17 | * {@inheritdoc} 18 | */ 19 | public function accept() { 20 | /** @var \Drupal\Signify\VerifierFileChecksum $checksum */ 21 | $checksum = $this->current(); 22 | $hash_file_path = $this->workingDirectory . DIRECTORY_SEPARATOR . $checksum->filename; 23 | $algorithm = strtolower($checksum->algorithm); 24 | $hash = @hash_file($algorithm, $hash_file_path); 25 | return $hash !== $checksum->hex_hash; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Verifier.php: -------------------------------------------------------------------------------- 1 | publicKeyRaw = $public_key_raw; 34 | } 35 | 36 | /** 37 | * Get the raw public key in use. 38 | * 39 | * @return string 40 | * The public key. 41 | */ 42 | public function getPublicKeyRaw(){ 43 | return $this->publicKeyRaw; 44 | } 45 | 46 | /** 47 | * Get the public key data. 48 | * 49 | * @return \Drupal\Signify\VerifierB64Data 50 | * An object with the validated and decoded public key data. 51 | * 52 | * @throws \Drupal\Signify\VerifierException 53 | */ 54 | public function getPublicKey() { 55 | if (!$this->publicKey) { 56 | $this->publicKey = $this->parseB64String($this->publicKeyRaw, SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES); 57 | } 58 | return $this->publicKey; 59 | } 60 | 61 | /** 62 | * Gets a \DateTime object modeling "now". 63 | * 64 | * @return \DateTime 65 | * An object representing the current date in UTC for checking expiration. 66 | */ 67 | public function getNow(\DateTime $now = NULL) 68 | { 69 | $date_format = 'Y-m-d'; 70 | if (empty($now)) { 71 | $now = gmdate($date_format); 72 | } 73 | else { 74 | $now = $now->format($date_format); 75 | } 76 | $now_dt = \DateTime::createFromFormat($date_format, $now, new \DateTimeZone('UTC')); 77 | if (!$now_dt instanceof \DateTime) { 78 | throw new VerifierException('Unexpected date format of current date.'); 79 | } 80 | return $now_dt; 81 | } 82 | 83 | /** 84 | * Parse the contents of a base 64 encoded file. 85 | * 86 | * @param string $b64 87 | * The file contents. 88 | * @param int $length 89 | * The length of the data, either 32 or 64 bytes. 90 | * 91 | * @return \Drupal\Signify\VerifierB64Data 92 | * An object with the validated and decoded data. 93 | * 94 | * @throws \Drupal\Signify\VerifierException 95 | */ 96 | public function parseB64String($b64, $length) { 97 | $parts = explode("\n", $b64); 98 | if (count($parts) !== 3) { 99 | throw new VerifierException("Invalid format; must contain two newlines, one after comment and one after base64"); 100 | } 101 | $comment = $parts[0]; 102 | if (substr($comment, 0, self::COMMENTHDRLEN) !== self::COMMENTHDR) { 103 | throw new VerifierException(sprintf("Invalid format; comment must start with '%s'", self::COMMENTHDR)); 104 | } 105 | if (strlen($comment) > self::COMMENTHDRLEN + self::COMMENTMAXLEN) { 106 | throw new VerifierException(sprintf("Invalid format; comment longer than %d bytes", self::COMMENTMAXLEN)); 107 | } 108 | return new VerifierB64Data($parts[1], $length); 109 | } 110 | 111 | /** 112 | * Verify a string message signed with plain Signify format. 113 | * 114 | * @param string $signed_message 115 | * The string contents of the signify signature and message (e.g. the contents of a .sig file.) 116 | * 117 | * @return string 118 | * The message if the verification passed. 119 | * 120 | * @throws \SodiumException 121 | * Thrown when there is an unexpected crypto error or missing library. 122 | * @throws \Drupal\Signify\VerifierException 123 | * Thrown when the message was not verified by the signature. 124 | */ 125 | public function verifyMessage($signed_message) { 126 | $pubkey = $this->getPublicKey(); 127 | 128 | // Simple split of signify signature and embedded message; input 129 | // validation occurs in parseB64String(). 130 | $embedded_message_index = 0; 131 | for($i = 1; $i <= 2 && $embedded_message_index !== false; $i++) { 132 | $embedded_message_index = strpos($signed_message, "\n", $embedded_message_index + 1); 133 | } 134 | $signature = substr($signed_message, 0, $embedded_message_index + 1); 135 | $message = substr($signed_message, $embedded_message_index + 1); 136 | if ($message === false) { 137 | $message = ''; 138 | } 139 | 140 | $sig = $this->parseB64String($signature, SODIUM_CRYPTO_SIGN_BYTES); 141 | if ($pubkey->keyNum !== $sig->keyNum) { 142 | throw new VerifierException('verification failed: checked against wrong key'); 143 | } 144 | $valid = sodium_crypto_sign_verify_detached($sig->data, $message, $pubkey->data); 145 | if (!$valid) { 146 | throw new VerifierException('Signature did not match'); 147 | } 148 | return $message; 149 | } 150 | 151 | /** 152 | * Verify a signed checksum list, and then verify the checksum for each file in the list. 153 | * 154 | * @param string $signed_checksum_list 155 | * Contents of a signify signature file whose message is a file checksum list. 156 | * @param string $working_directory 157 | * A directory on the filesystem that the file checksum list is relative to. 158 | * 159 | * @return int 160 | * The number of files verified. 161 | * 162 | * @throws \SodiumException 163 | * @throws \Drupal\Signify\VerifierException 164 | * Thrown when the checksum list could not be verified by the signature, or a listed file could not be verified. 165 | */ 166 | public function verifyChecksumList($signed_checksum_list, $working_directory) 167 | { 168 | $checksum_list_raw = $this->verifyMessage($signed_checksum_list); 169 | return $this->verifyTrustedChecksumList($checksum_list_raw, $working_directory); 170 | } 171 | 172 | protected function verifyTrustedChecksumList($checksum_list_raw, $working_directory) { 173 | $checksum_list = new ChecksumList($checksum_list_raw, true); 174 | $failed_checksum_list = new FailedCheckumFilter($checksum_list, $working_directory); 175 | 176 | foreach ($failed_checksum_list as $file_checksum) 177 | { 178 | // Don't just rely on a list of failed checksums, throw a more 179 | // specific exception. 180 | $actual_hash = @hash_file(strtolower($file_checksum->algorithm), $working_directory . DIRECTORY_SEPARATOR . $file_checksum->filename); 181 | // If file doesn't exist or isn't readable, hash_file returns false. 182 | if ($actual_hash === false) { 183 | throw new VerifierException("File \"$file_checksum->filename\" in the checksum list could not be read."); 184 | } 185 | // Any hash less than 64 is not secure. 186 | if (empty($actual_hash) || strlen($actual_hash) < 64) { 187 | throw new VerifierException("Failure computing hash for file \"$file_checksum->filename\" in the checksum list."); 188 | } 189 | // This method is used because hash_equals was added in PHP 5.6. 190 | // And we don't need timing safe comparisons. 191 | if ($actual_hash !== $file_checksum->hex_hash) 192 | { 193 | throw new VerifierException("File \"$file_checksum->filename\" does not pass checksum verification."); 194 | } 195 | } 196 | 197 | return $checksum_list->count(); 198 | } 199 | 200 | /** 201 | * Verify the a signed checksum list file, and then verify the checksum for each file in the list. 202 | * 203 | * @param string $checksum_file 204 | * The filename of a signed checksum list file. 205 | * @return int 206 | * The number of files that were successfully verified. 207 | * @throws \SodiumException 208 | * @throws \Drupal\Signify\VerifierException 209 | * Thrown when the checksum list could not be verified by the signature, or a listed file could not be verified. 210 | */ 211 | public function verifyChecksumFile($checksum_file) { 212 | $absolute_path = realpath($checksum_file); 213 | if (empty($absolute_path)) 214 | { 215 | throw new VerifierException("The real path of checksum list file at \"$checksum_file\" could not be determined."); 216 | } 217 | $working_directory = dirname($absolute_path); 218 | if (is_dir($absolute_path)) { 219 | throw new VerifierException("The checksum list file at \"$checksum_file\" is a directory, not a file."); 220 | } 221 | $signed_checksum_list = @file_get_contents($absolute_path); 222 | if (empty($signed_checksum_list)) 223 | { 224 | throw new VerifierException("The checksum list file at \"$checksum_file\" could not be read."); 225 | } 226 | 227 | return $this->verifyChecksumList($signed_checksum_list, $working_directory); 228 | } 229 | 230 | /** 231 | * Verify a string message signed with CSIG chained-signature extended Signify format. 232 | * 233 | * @param string $chained_signed_message 234 | * The string contents of the root/intermediate chained signify signature and message (e.g. the contents of a .csig file.) 235 | * @param \DateTime $now 236 | * If provided, a \DateTime object modeling "now". 237 | * 238 | * @return string 239 | * The message if the verification passed. 240 | * @throws \SodiumException 241 | * @throws \Drupal\Signify\VerifierException 242 | * Thrown when the message was not verified. 243 | */ 244 | public function verifyCsigMessage($chained_signed_message, \DateTime $now = NULL) 245 | { 246 | $csig_lines = explode("\n", $chained_signed_message, 6); 247 | $root_signed_intermediate_key_and_validity = implode("\n", array_slice($csig_lines, 0, 5)) . "\n"; 248 | $this->verifyMessage($root_signed_intermediate_key_and_validity); 249 | 250 | $valid_through_dt = \DateTime::createFromFormat('Y-m-d', $csig_lines[2], new \DateTimeZone('UTC')); 251 | if (! $valid_through_dt instanceof \DateTime) 252 | { 253 | throw new VerifierException('Unexpected valid-through date format.'); 254 | } 255 | $now_dt = $this->getNow($now); 256 | 257 | $diff = $now_dt->diff($valid_through_dt); 258 | if ($diff->invert) { 259 | throw new VerifierException(sprintf('The intermediate key expired %d day(s) ago.', $diff->days)); 260 | } 261 | 262 | $intermediate_pubkey = implode("\n", array_slice($csig_lines, 3, 2)) . "\n"; 263 | $chained_verifier = new self($intermediate_pubkey); 264 | $signed_message = implode("\n", array_slice($csig_lines, 5)); 265 | return $chained_verifier->verifyMessage($signed_message); 266 | } 267 | 268 | /** 269 | * Verify a signed checksum list, and then verify the checksum for each file in the list. 270 | * 271 | * @param string $csig_signed_checksum_list 272 | * Contents of a CSIG signature file whose message is a file checksum list. 273 | * @param string $working_directory 274 | * A directory on the filesystem that the file checksum list is relative to. 275 | * @param \DateTime $now 276 | * If provided, a \DateTime object modeling "now". 277 | * 278 | * @return int 279 | * The number of files verified. 280 | * 281 | * @throws \SodiumException 282 | * @throws \Drupal\Signify\VerifierException 283 | * Thrown when the checksum list could not be verified by the signature, or a listed file could not be verified. 284 | */ 285 | public function verifyCsigChecksumList($csig_signed_checksum_list, $working_directory, \DateTime $now = NULL) 286 | { 287 | $checksum_list_raw = $this->verifyCsigMessage($csig_signed_checksum_list, $now); 288 | return $this->verifyTrustedChecksumList($checksum_list_raw, $working_directory); 289 | } 290 | 291 | /** 292 | * Verify the a signed checksum list file, and then verify the checksum for each file in the list. 293 | * 294 | * @param string $checksum_file 295 | * The filename of a .csig signed checksum list file. 296 | * @param \DateTime $now 297 | * If provided, a \DateTime object modeling "now". 298 | * 299 | * @return int 300 | * The number of files that were successfully verified. 301 | * 302 | * @throws \SodiumException 303 | * @throws \Drupal\Signify\VerifierException 304 | * Thrown when the checksum list could not be verified by the signature, or a listed file could not be verified. 305 | */ 306 | public function verifyCsigChecksumFile($csig_checksum_file, \DateTime $now = NULL) 307 | { 308 | $absolute_path = realpath($csig_checksum_file); 309 | if (empty($absolute_path)) 310 | { 311 | throw new VerifierException("The real path of checksum list file at \"$csig_checksum_file\" could not be determined."); 312 | } 313 | $working_directory = dirname($absolute_path); 314 | if (is_dir($absolute_path)) { 315 | throw new VerifierException("The checksum list file at \"$csig_checksum_file\" is a directory, not a file."); 316 | } 317 | $signed_checksum_list = file_get_contents($absolute_path); 318 | if (empty($signed_checksum_list)) 319 | { 320 | throw new VerifierException("The checksum list file at \"$csig_checksum_file\" could not be read."); 321 | } 322 | 323 | return $this->verifyCsigChecksumList($signed_checksum_list, $working_directory, $now); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/VerifierB64Data.php: -------------------------------------------------------------------------------- 1 | keyNum = substr($decoded, 2, self::KEYNUMLEN); 20 | $this->data = substr($decoded, 2 + self::KEYNUMLEN); 21 | if ($length !== SODIUM_CRYPTO_SIGN_BYTES && $length !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { 22 | throw new VerifierException(sprintf('Length must be %d or %d. Got %d', SODIUM_CRYPTO_SIGN_BYTES, SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, $length)); 23 | } 24 | if (strlen($this->data) !== $length) { 25 | throw new VerifierException('Data does not match expected length.'); 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/VerifierException.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 35 | $this->algorithm = $algorithm; 36 | $this->hex_hash = $hex_hash; 37 | $this->trusted = $trusted; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/SignifyTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(is_object($var)); 27 | } 28 | 29 | /** 30 | * Tests a successful message verification. 31 | */ 32 | public function testPositiveVerification() 33 | { 34 | $public_key = file_get_contents(__DIR__ . '/fixtures/test1-php-signify.pub'); 35 | $var = new Verifier($public_key); 36 | $this->assertSame($public_key, $var->getPublicKeyRaw()); 37 | $signature = file_get_contents(__DIR__ . '/fixtures/artifact1.php.sig'); 38 | $message = file_get_contents(__DIR__ . '/fixtures/artifact1.php'); 39 | $this->assertEquals($message, $var->verifyMessage($signature . $message)); 40 | } 41 | 42 | /** 43 | * Tests a successful embedded signature and message verification. 44 | */ 45 | public function testPositiveVerificationEmbeddedMessage() 46 | { 47 | $public_key = file_get_contents(__DIR__ . '/fixtures/embed.pub'); 48 | $var = new Verifier($public_key); 49 | $this->assertSame($public_key, $var->getPublicKeyRaw()); 50 | $signature_and_message = file_get_contents(__DIR__ . '/fixtures/embed.sig'); 51 | $message = file_get_contents(__DIR__ . '/fixtures/embed.txt'); 52 | $this->assertEquals($message, $var->verifyMessage($signature_and_message)); 53 | } 54 | 55 | /** 56 | * Test using the wrong public key. 57 | */ 58 | public function testIncorrectPubkey() 59 | { 60 | $public_key = file_get_contents(__DIR__ . '/fixtures/test2-php-signify.pub'); 61 | $var = new Verifier($public_key); 62 | $signature = file_get_contents(__DIR__ . '/fixtures/artifact1.php.sig'); 63 | $message = file_get_contents(__DIR__ . '/fixtures/artifact1.php'); 64 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 65 | $this->expectExceptionMessageWrapper('checked against wrong key'); 66 | $var->verifyMessage($signature . $message); 67 | } 68 | 69 | /** 70 | * Test with a modified message string compared to what was signed. 71 | */ 72 | public function testIncorrectMessage() 73 | { 74 | $public_key = file_get_contents(__DIR__ . '/fixtures/test1-php-signify.pub'); 75 | $var = new Verifier($public_key); 76 | $signature = file_get_contents(__DIR__ . '/fixtures/artifact1.php.sig'); 77 | $message = file_get_contents(__DIR__ . '/fixtures/artifact1.php') . 'bad message'; 78 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 79 | $this->expectExceptionMessageWrapper('Signature did not match'); 80 | $var->verifyMessage($signature . $message); 81 | } 82 | 83 | /** 84 | * @dataProvider invalidPublicKeys 85 | */ 86 | public function testInvalidPublicKey($public_key, $exception_message) 87 | { 88 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 89 | $this->expectExceptionMessageWrapper($exception_message); 90 | $verifier = new Verifier($public_key); 91 | $verifier->getPublicKey(); 92 | } 93 | 94 | /** 95 | * Data provider for testInvalidPublicKey() 96 | */ 97 | public function invalidPublicKeys() 98 | { 99 | $public_key = file_get_contents(__DIR__ . '/fixtures/test1-php-signify.pub'); 100 | 101 | $missing_comment = implode("\n", array_slice(explode("\n", $public_key), 1)); 102 | $truncated_key = substr($public_key, 0, -3) . "\n"; 103 | $wrong_comment = str_replace('untrusted', 'super trustworthy', $public_key); 104 | 105 | $pk_parts = explode("\n", $public_key); 106 | $epic_comment = str_pad($pk_parts[0], Verifier::COMMENTHDRLEN + Verifier::COMMENTMAXLEN + 1, 'x') . "\n" . implode("\n", array_slice($pk_parts, 1)); 107 | 108 | return array( 109 | array($missing_comment, 'must contain two newlines'), 110 | array($truncated_key, 'Data does not match expected length'), 111 | array($wrong_comment, 'comment must start with'), 112 | array($epic_comment, sprintf('comment longer than %d bytes', Verifier::COMMENTMAXLEN)) 113 | ); 114 | } 115 | 116 | public function testVerifyChecksumList() 117 | { 118 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 119 | $var = new Verifier($public_key); 120 | $signed_checksumlist = file_get_contents(__DIR__ . '/fixtures/checksumlist.sig'); 121 | $this->assertEquals(1, $var->verifyChecksumList($signed_checksumlist, __DIR__ . '/fixtures')); 122 | } 123 | 124 | public function testVerifyChecksumFile() 125 | { 126 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 127 | $var = new Verifier($public_key); 128 | $this->assertEquals(1, $var->verifyChecksumFile(__DIR__ . '/fixtures/checksumlist.sig')); 129 | } 130 | 131 | /** 132 | */ 133 | public function testChecksumFileCompromisedArchive() 134 | { 135 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 136 | $var = new Verifier($public_key); 137 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 138 | $this->expectExceptionMessageWrapper('File "payload-compromised.zip" does not pass checksum verification.'); 139 | $var->verifyChecksumFile(__DIR__ . '/fixtures/checksumlist-compromised.sig'); 140 | } 141 | 142 | /** 143 | */ 144 | public function testChecksumFileMissing() 145 | { 146 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 147 | $var = new Verifier($public_key); 148 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 149 | $this->expectExceptionMessageWrapper('The real path of checksum list file at'); 150 | $var->verifyChecksumFile(__DIR__ . '/fixtures/not_a_file'); 151 | } 152 | 153 | /** 154 | */ 155 | public function testChecksumFileItselfUnreadable() 156 | { 157 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 158 | $var = new Verifier($public_key); 159 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 160 | $this->expectExceptionMessageWrapper('is a directory, not a file.'); 161 | $var->verifyChecksumFile(__DIR__ . '/fixtures'); 162 | } 163 | 164 | /** 165 | */ 166 | public function testChecksumListUnreadableFile() 167 | { 168 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 169 | $var = new Verifier($public_key); 170 | $signed_checksumlist = file_get_contents(__DIR__ . '/fixtures/checksumlist.sig'); 171 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 172 | $this->expectExceptionMessageWrapper('File "payload.zip" in the checksum list could not be read.'); 173 | $var->verifyChecksumList($signed_checksumlist, __DIR__ . '/intentionally wrong path'); 174 | } 175 | 176 | public function testDefaultGetNow() 177 | { 178 | $public_key = file_get_contents(__DIR__ . '/fixtures/checksumlist.pub'); 179 | $var = new Verifier($public_key); 180 | $now = new \DateTime('now', new \DateTimeZone('UTC')); 181 | $this->assertLessThan(5, $now->diff($var->getNow())->s); 182 | } 183 | 184 | /** 185 | * @dataProvider positiveCsigVerificationProvider 186 | */ 187 | public function testPositiveCsigVerification($now) 188 | { 189 | $public_key = file_get_contents(__DIR__ . '/fixtures/intermediate/root.pub'); 190 | $var = new Verifier($public_key); 191 | $chained_signed_message = file_get_contents(__DIR__ . '/fixtures/intermediate/checksumlist.csig'); 192 | $message = $var->verifyCsigMessage($chained_signed_message, $now); 193 | 194 | $this->assertEquals( 195 | "SHA512 (payload.zip) = c3d7e5cd9b117c602e6a3063a9c6f28171a65678fbc0789c1517eecd02f4542267f2db0a59e32a35763abcf0f7601df2b7e2d792c1fa2b9f18bfafa61c121380\n", 196 | $message 197 | ); 198 | } 199 | 200 | public function positiveCsigVerificationProvider() 201 | { 202 | return array( 203 | array(new \DateTime('2000-01-01', new \DateTimeZone('UTC'))), 204 | array(new \DateTime('2019-09-09', new \DateTimeZone('UTC'))), 205 | array(new \DateTime('2019-09-10', new \DateTimeZone('UTC'))), 206 | ); 207 | } 208 | 209 | /** 210 | */ 211 | public function testExpiredCsigVerification() 212 | { 213 | $public_key = file_get_contents(__DIR__ . '/fixtures/intermediate/root.pub'); 214 | $var = new Verifier($public_key); 215 | $chained_signed_message = file_get_contents(__DIR__ . '/fixtures/intermediate/checksumlist.csig'); 216 | $this->expectExceptionWrapper('Drupal\Signify\VerifierException'); 217 | $this->expectExceptionMessageWrapper('The intermediate key expired 1 day(s) ago.'); 218 | $var->verifyCsigMessage($chained_signed_message, new \DateTime('2019-09-11', new \DateTimeZone('UTC'))); 219 | } 220 | 221 | public function testVerifyCsigChecksumList() 222 | { 223 | $public_key = file_get_contents(__DIR__ . '/fixtures/intermediate/root.pub'); 224 | $var = new Verifier($public_key); 225 | $signed_checksumlist = file_get_contents(__DIR__ . '/fixtures/intermediate/checksumlist.csig'); 226 | $this->assertEquals(1, $var->verifyCsigChecksumList($signed_checksumlist, __DIR__ . '/fixtures/intermediate', new \DateTime('2019-09-01', new \DateTimeZone('UTC')))); 227 | } 228 | 229 | public function testVerifyCsigChecksumFile() 230 | { 231 | $public_key = file_get_contents(__DIR__ . '/fixtures/intermediate/root.pub'); 232 | $var = new Verifier($public_key); 233 | $this->assertEquals(1, $var->verifyCsigChecksumFile(__DIR__ . '/fixtures/intermediate/checksumlist.csig', new \DateTime('2019-09-01', new \DateTimeZone('UTC')))); 234 | } 235 | 236 | public function testMultipleFilesCsig() { 237 | $public_key = file_get_contents(__DIR__ . '/fixtures/multiple-files/root.pub'); 238 | $var = new Verifier($public_key); 239 | $contents = file_get_contents(__DIR__ . '/fixtures/multiple-files/module.csig'); 240 | $files = $var->verifyCsigMessage($contents, new \DateTime('2019-09-20', new \DateTimeZone('UTC'))); 241 | $checksums = new ChecksumList($files, TRUE); 242 | 243 | // Validate expected checksums exist. 244 | $checksums->rewind(); 245 | $a = $checksums->current(); 246 | $this->assertEquals('a.txt', $a->filename); 247 | $this->assertCount(4, $checksums); 248 | 249 | // Validate failed checkusms. 250 | $failed_checksums = new FailedCheckumFilter($checksums, __DIR__ . '/fixtures/multiple-files'); 251 | $failed_checksums->rewind(); 252 | $b = $failed_checksums->current(); 253 | $this->assertEquals('b.txt', $b->filename); 254 | $failed_checksums->next(); 255 | $d = $failed_checksums->current(); 256 | $this->assertEquals('d.txt', $d->filename); 257 | $this->assertCount(2, $failed_checksums); 258 | } 259 | 260 | public function expectExceptionWrapper($exception) 261 | { 262 | if (is_callable(array('parent', 'expectException'))) { 263 | parent::expectException($exception); 264 | } else { 265 | $this->setExpectedException($exception); 266 | } 267 | } 268 | 269 | public function expectExceptionMessageWrapper($message) 270 | { 271 | if (is_callable(array('parent', 'expectExceptionMessage'))) { 272 | parent::expectExceptionMessage($message); 273 | } else { 274 | $this->setExpectedException('\Exception', $message); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /tests/fixtures/artifact1.php: -------------------------------------------------------------------------------- 1 |