Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 252
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
GpgCrypt
0.00% covered (danger)
0.00%
0 / 252
0.00% covered (danger)
0.00%
0 / 18
2756
0.00% covered (danger)
0.00%
0 / 1
 getCreateDescriptors
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyDescriptors
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 updateDbForTallyJob
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 cleanupDbForTallyJob
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 checkEncryptKey
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 checkSignKey
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setupHome
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 adHocDebug
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 setupHomeAndKeys
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 importKey
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 cleanup
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 deleteDir
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 runGpg
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 encrypt
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 decrypt
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 canDecrypt
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateTallyContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Crypt;
4
5use MediaWiki\Extension\SecurePoll\Context;
6use MediaWiki\Extension\SecurePoll\Entities\Election;
7use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\Shell\Shell;
10use MediaWiki\Status\Status;
11use MWCryptRand;
12use Wikimedia\Rdbms\IDatabase;
13
14/**
15 * Cryptography module that shells out to GPG
16 *
17 * Election properties used:
18 *     gpg-encrypt-key:    The public key used for encrypting (from gpg --export)
19 *     gpg-sign-key:       The private key used for signing (from gpg --export-secret-keys)
20 *     gpg-decrypt-key:    The private key used for decrypting.
21 *
22 * Generally only gpg-encrypt-key and gpg-sign-key are required for voting,
23 * gpg-decrypt-key is for tallying.
24 */
25class GpgCrypt extends Crypt {
26    /** @var Context|null */
27    public $context;
28    /** @var Election|null */
29    public $election;
30    /** @var string|null */
31    public $recipient;
32    /** @var string|null */
33    public $signer;
34    /** @var string|null */
35    public $homeDir;
36
37    public static function getCreateDescriptors() {
38        global $wgSecurePollGpgSignKey;
39
40        $ret = parent::getCreateDescriptors();
41        $ret['election'] += [
42            'gpg-encrypt-key' => [
43                'label-message' => 'securepoll-create-label-gpg_encrypt_key',
44                'type' => 'textarea',
45                'SecurePoll_type' => 'property',
46                'rows' => 5,
47                'validation-callback' => [ self::class, 'checkEncryptKey' ],
48            ],
49        ];
50
51        if ( $wgSecurePollGpgSignKey ) {
52            $ret['election'] += [
53                'gpg-sign-key' => [
54                    'type' => 'api',
55                    'default' => $wgSecurePollGpgSignKey,
56                    'SecurePoll_type' => 'property',
57                ],
58            ];
59        } else {
60            $ret['election'] += [
61                'gpg-sign-key' => [
62                    'label-message' => 'securepoll-create-label-gpg_sign_key',
63                    'type' => 'textarea',
64                    'SecurePoll_type' => 'property',
65                    'rows' => 5,
66                    'validation-callback' => [ self::class, 'checkSignKey' ],
67                ],
68            ];
69        }
70
71        return $ret;
72    }
73
74    public function getTallyDescriptors(): array {
75        return [
76            'gpg-decrypt-key' => [
77                'label-message' => 'securepoll-tally-gpg-decrypt-key',
78                'type' => 'textarea',
79                'required' => true,
80                'rows' => 5,
81                'validation-callback' => [ self::class, 'checkEncryptKey' ],
82            ],
83        ];
84    }
85
86    public function updateDbForTallyJob(
87        int $electionId,
88        IDatabase $dbw,
89        array $data
90    ): void {
91        // Add private key to DB if it was entered in the form
92        if ( isset( $data['gpg-decrypt-key'] ) ) {
93            $dbw->newInsertQueryBuilder()
94                ->insertInto( 'securepoll_properties' )
95                ->row( [
96                    'pr_entity' => $electionId,
97                    'pr_key' => 'gpg-decrypt-key',
98                    'pr_value' => $data['gpg-decrypt-key'],
99                ] )
100                ->onDuplicateKeyUpdate()
101                ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
102                ->set( [
103                    'pr_entity' => $electionId,
104                    'pr_key' => 'gpg-decrypt-key',
105                    'pr_value' => $data['gpg-decrypt-key'],
106                ] )
107                ->caller( __METHOD__ )
108                ->execute();
109            $dbw->newInsertQueryBuilder()
110                ->insertInto( 'securepoll_properties' )
111                ->ignore()
112                ->row( [
113                    'pr_entity' => $electionId,
114                    'pr_key' => 'delete-gpg-decrypt-key',
115                    'pr_value' => 1,
116                ] )
117                ->caller( __METHOD__ )
118                ->execute();
119        }
120    }
121
122    public function cleanupDbForTallyJob( int $electionId, IDatabase $dbw ): void {
123        $result = $dbw->newSelectQueryBuilder()
124            ->select( 'pr_entity' )
125            ->from( 'securepoll_properties' )
126            ->where( [
127                'pr_entity' => $electionId,
128                'pr_key' => 'delete-gpg-decrypt-key',
129            ] )
130            ->caller( __METHOD__ )
131            ->fetchResultSet();
132
133        // Only delete key if it was added for this job
134        if ( !$result->numRows() ) {
135            return;
136        }
137
138        $dbw->newDeleteQueryBuilder()
139            ->deleteFrom( 'securepoll_properties' )
140            ->where( [
141                'pr_entity' => $electionId,
142                'pr_key' => [ 'gpg-decrypt-key', 'delete-gpg-decrypt-key' ],
143            ] )
144            ->caller( __METHOD__ )
145            ->execute();
146    }
147
148    public static function checkEncryptKey( $key ) {
149        if ( $key === '' ) {
150            return Status::newFatal( 'htmlform-required' )->getMessage();
151        }
152        $that = new GpgCrypt( null, null );
153        $status = $that->setupHome();
154        if ( $status->isOK() ) {
155            $status = $that->importKey( $key );
156        }
157        $that->cleanup();
158
159        return $status->isOK() ? true : $status->getMessage();
160    }
161
162    public static function checkSignKey( $key ) {
163        if ( !strval( $key ) ) {
164            return true;
165        }
166
167        $that = new GpgCrypt( null, null );
168        $status = $that->setupHome();
169        if ( $status->isOK() ) {
170            $status = $that->importKey( $key );
171        }
172        $that->cleanup();
173
174        return $status->isOK() ? true : $status->getMessage();
175    }
176
177    /**
178     * Constructor.
179     * @param Context|null $context
180     * @param Election|null $election
181     */
182    public function __construct( $context, $election ) {
183        $this->context = $context;
184        $this->election = $election;
185    }
186
187    /**
188     * Create a new GPG home directory
189     * @return Status
190     */
191    public function setupHome() {
192        global $wgSecurePollTempDir;
193        if ( $this->homeDir ) {
194            # Already done
195            return Status::newGood();
196        }
197
198        # Create the directory
199        $this->homeDir = $wgSecurePollTempDir . '/securepoll-' . MWCryptRand::generateHex( 40 );
200        if ( !mkdir( $this->homeDir ) ) {
201            $this->homeDir = null;
202
203            return Status::newFatal( 'securepoll-no-gpg-home' );
204        }
205        chmod( $this->homeDir, 0700 );
206
207        // T288366 Tallies fail on beta/prod with little visibility
208        // Add logging to gain more context into where it fails
209        $this->adHocDebug(
210            'Created the temp directory for GPG decryption',
211            [
212                'tmpDir' => $this->homeDir,
213            ]
214        );
215
216        return Status::newGood();
217    }
218
219    /**
220     * Log the message and context to the AdHocDebug channel.
221     *
222     * @see https://phabricator.wikimedia.org/T288366
223     *
224     * @param string $message
225     * @param array $context
226     */
227    private function adHocDebug( string $message, array $context = [] ) {
228        if ( $this->election ) {
229            $context += [
230                'electionId' => $this->election->getId(),
231            ];
232        }
233
234        LoggerFactory::getInstance( 'AdHocDebug' )
235            ->info( $message, $context );
236    }
237
238    /**
239     * Create a new GPG home directory and import keys
240     * @return Status
241     */
242    public function setupHomeAndKeys() {
243        $status = $this->setupHome();
244        if ( !$status->isOK() ) {
245            return $status;
246        }
247
248        if ( $this->recipient ) {
249            # Already done
250            return Status::newGood();
251        }
252
253        # Fetch the keys
254        $encryptKey = strval( $this->election->getProperty( 'gpg-encrypt-key' ) );
255        if ( $encryptKey === '' ) {
256            throw new InvalidDataException( 'GPG keys are configured incorrectly' );
257        }
258
259        # Import the encryption key
260        $status = $this->importKey( $encryptKey );
261        if ( !$status->isOK() ) {
262            return $status;
263        }
264        $this->recipient = $status->value;
265
266        # Import the sign key
267        $signKey = strval( $this->election->getProperty( 'gpg-sign-key' ) );
268        if ( $signKey ) {
269            $status = $this->importKey( $signKey );
270            if ( !$status->isOK() ) {
271                return $status;
272            }
273            $this->signer = $status->value;
274        } else {
275            $this->signer = null;
276        }
277
278        return Status::newGood();
279    }
280
281    /**
282     * Import a given exported key.
283     * @param string $key The full key data.
284     * @return Status
285     */
286    public function importKey( $key ) {
287        # Import the key
288        file_put_contents( "{$this->homeDir}/key", $key );
289        $status = $this->runGpg( '--import', "{$this->homeDir}/key" );
290        if ( !$status->isOK() ) {
291            return $status;
292        }
293        # Extract the key ID
294        if ( !preg_match( '/^gpg: key (\w+):/m', $status->value, $m ) ) {
295            return Status::newFatal( 'securepoll-gpg-parse-error' );
296        }
297
298        // T288366 Tallies fail on beta/prod with little visibility
299        // Add logging to gain more context into where it fails
300        $this->adHocDebug(
301            'Imported GPG decryption key',
302            [
303                'fileLocation' => "{$this->homeDir}/key",
304            ]
305        );
306
307        return Status::newGood( $m[1] );
308    }
309
310    /**
311     * @internal for use by classes that call GpgCrypt
312     * because cleanup has to happen after all decryptions
313     *
314     * Delete the temporary home directory
315     */
316    public function cleanup() {
317        if ( !$this->homeDir ) {
318            return;
319        }
320
321        $this->deleteDir( $this->homeDir );
322        $this->homeDir = null;
323        $this->recipient = null;
324    }
325
326    private function deleteDir( $dirname ) {
327        $dir = opendir( $dirname );
328        if ( !$dir ) {
329            return;
330        }
331
332        // @codingStandardsIgnoreStart
333        while ( false !== ( $file = readdir( $dir ) ) ) {
334            // @codingStandardsIgnoreEnd
335            if ( $file == '.' || $file == '..' ) {
336                continue;
337            }
338            if ( !is_dir( "$dirname/$file" ) ) {
339                unlink( "$dirname/$file" );
340            } else {
341                $this->deleteDir( "$dirname/$file" );
342            }
343        }
344        closedir( $dir );
345        rmdir( $dirname );
346
347        // T288366 Tallies fail on beta/prod with little visibility
348        // Add logging to gain more context into where it fails
349        $this->adHocDebug( 'Cleaned up GPG data after tally' );
350    }
351
352    /**
353     * Shell out to GPG with the given additional command-line parameters
354     * @param string ...$params
355     * @return Status
356     */
357    protected function runGpg( ...$params ) {
358        global $wgSecurePollGPGCommand, $wgSecurePollShowErrorDetail;
359
360        $params = array_merge(
361            [
362                $wgSecurePollGPGCommand,
363                '--homedir',
364                $this->homeDir,
365                '--trust-model',
366                'always',
367                '--batch',
368                '--yes',
369            ],
370            $params
371        );
372        $command = Shell::command( $params )->disableSandbox()->includeStderr();
373
374        $result = $command->execute();
375
376        if ( $result->getExitCode() ) {
377            if ( $wgSecurePollShowErrorDetail ) {
378                return Status::newFatal(
379                    'securepoll-full-gpg-error',
380                    (string)$command,
381                    $result->getStdout()
382                );
383            } else {
384                return Status::newFatal( 'securepoll-secret-gpg-error' );
385            }
386        } else {
387            return Status::newGood( $result->getStdout() );
388        }
389    }
390
391    /**
392     * Encrypt some data. When successful, the value member of the Status object
393     * will contain the encrypted record.
394     * @param string $record
395     * @return Status
396     */
397    public function encrypt( $record ) {
398        $status = $this->setupHomeAndKeys();
399        if ( !$status->isOK() ) {
400            $this->cleanup();
401
402            return $status;
403        }
404
405        # Write unencrypted record
406        file_put_contents( "{$this->homeDir}/input", $record );
407
408        # Call GPG
409        $args = array_merge(
410            [
411                '--encrypt',
412                '--armor',
413                # Don't use compression, this may leak information about the plaintext
414                '--compress-level',
415                '0',
416                '--recipient',
417                $this->recipient,
418            ],
419            $this->signer !== null ? [
420                '--sign',
421                '--local-user',
422                $this->signer,
423            ] : [],
424            [
425                // Don't use --output due to T258763
426                '-o',
427                "{$this->homeDir}/output",
428                "{$this->homeDir}/input",
429            ]
430        );
431        $status = $this->runGpg( ...$args );
432
433        # Read result
434        if ( $status->isOK() ) {
435            $status->value = file_get_contents( "{$this->homeDir}/output" );
436        }
437
438        # Delete temporary files
439        $this->cleanup();
440
441        return $status;
442    }
443
444    /**
445     * Decrypt some data. When successful, the value member of the Status object
446     * will contain the encrypted record.
447     * @param string $encrypted
448     * @return Status
449     */
450    public function decrypt( $encrypted ) {
451        $status = $this->setupHomeAndKeys();
452        if ( !$status->isOK() ) {
453
454            $this->cleanup();
455
456            return $status;
457        }
458
459        # Import the decryption key
460        $decryptKey = $this->context->decryptData[ 'gpg-decrypt-key' ] ??
461            strval( $this->election->getProperty( 'gpg-decrypt-key' ) );
462        if ( $decryptKey === '' ) {
463            $this->cleanup();
464
465            return Status::newFatal( 'securepoll-no-decryption-key' );
466        }
467        $this->importKey( $decryptKey );
468
469        # Write out encrypted record
470        file_put_contents( "{$this->homeDir}/input", $encrypted );
471
472        # Call GPG
473        $status = $this->runGpg(
474            '--decrypt',
475            // Don't use --output due to T258763
476            '-o',
477            "{$this->homeDir}/output",
478            "{$this->homeDir}/input"
479        );
480
481        # Read result
482        if ( $status->isOK() ) {
483            // T288366 Tallies fail on beta/prod with little visibility
484            // Add logging to gain more context into where it fails
485            $this->adHocDebug( 'Successfully decrypted vote' );
486
487            $status->value = file_get_contents( "{$this->homeDir}/output" );
488        }
489
490        return $status;
491    }
492
493    /**
494     * @return bool
495     */
496    public function canDecrypt() {
497        $decryptKey = strval( $this->election->getProperty( 'gpg-decrypt-key' ) );
498
499        return $decryptKey !== '';
500    }
501
502    /**
503     * Update the given context with any information needed for tallying.
504     *
505     * This allows some information, e.g. private keys, to be used for a
506     * single request and not added to the database.
507     *
508     * @param Context $context
509     * @param array $data
510     */
511    public function updateTallyContext( Context $context, array $data ): void {
512    }
513}