├── .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 | 
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 |