Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 252 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
GpgCrypt | |
0.00% |
0 / 252 |
|
0.00% |
0 / 18 |
2756 | |
0.00% |
0 / 1 |
getCreateDescriptors | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
6 | |||
getTallyDescriptors | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
updateDbForTallyJob | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
6 | |||
cleanupDbForTallyJob | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
6 | |||
checkEncryptKey | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
checkSignKey | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setupHome | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
adHocDebug | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
setupHomeAndKeys | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
importKey | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
cleanup | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
deleteDir | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
runGpg | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
encrypt | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 | |||
decrypt | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
canDecrypt | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
updateTallyContext | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Crypt; |
4 | |
5 | use MediaWiki\Extension\SecurePoll\Context; |
6 | use MediaWiki\Extension\SecurePoll\Entities\Election; |
7 | use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException; |
8 | use MediaWiki\Logger\LoggerFactory; |
9 | use MediaWiki\Shell\Shell; |
10 | use MediaWiki\Status\Status; |
11 | use MWCryptRand; |
12 | use 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 | */ |
25 | class 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 | } |