Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.31% covered (warning)
62.31%
200 / 321
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenSslCrypt
62.31% covered (warning)
62.31%
200 / 321
31.58% covered (danger)
31.58%
6 / 19
482.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 encrypt
92.06% covered (success)
92.06%
58 / 63
0.00% covered (danger)
0.00%
0 / 1
8.03
 decrypt
77.78% covered (warning)
77.78%
63 / 81
0.00% covered (danger)
0.00%
0 / 1
27.31
 getErrorStatus
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 setupKeys
82.54% covered (warning)
82.54%
52 / 63
0.00% covered (danger)
0.00%
0 / 1
28.33
 clearErrors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 jwtEncode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 jwtDecode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cleanup
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 canDecrypt
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCreateDescriptors
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyDescriptors
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 checkPublicKey
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 checkPrivateKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 checkKeyInternal
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 updateTallyContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateDbForTallyJob
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 cleanupDbForTallyJob
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getIssuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Crypt;
4
5use JsonException;
6use MediaWiki\Extension\SecurePoll\Context;
7use MediaWiki\Extension\SecurePoll\Entities\Election;
8use MediaWiki\Message\Message;
9use MediaWiki\SpecialPage\SpecialPage;
10use MediaWiki\Status\Status;
11use OpenSSLAsymmetricKey;
12use RuntimeException;
13use Wikimedia\Rdbms\IDatabase;
14
15/**
16 * Cryptography module that uses the PHP openssl extension.
17 * At the moment, only RSA keys are supported, with a minimum size of 2048 bits.
18 *
19 * Election properties used:
20 *     openssl-encrypt-key:  The public key used for encrypting.
21 *     openssl-sign-key:     The private key used for signing.
22 *     openssl-decrypt-key:  The private key used for decrypting.
23 *     openssl-verify-key:   The public ky used for verification.
24 *
25 * Generally only openssl-encrypt-key and openssl-sign-key are required for voting,
26 * openssl-decrypt-key and openssl-verify-key are for tallying.
27 */
28class OpenSslCrypt extends Crypt {
29    private const CLAIM_TYPE_TOKEN_TYPE = 'typ';
30    private const CLAIM_TYPE_SIGNATURE_ALGORITHM = 'alg';
31    private const CLAIM_TYPE_ISSUER = 'iss';
32    private const CLAIM_TYPE_SUBJECT = 'sub';
33    private const CLAIM_TYPE_VOTE = 'mw-ext-sp-vot';
34    private const CLAIM_TYPE_ENCRYPT_ALGORITHM = 'mw-ext-sp-alg';
35    private const CLAIM_TYPE_ENVELOPE_KEY = 'mw-ext-sp-env';
36    private const CLAIM_TYPE_TAG = 'mw-ext-sp-tag';
37    private const CLAIM_TYPE_IV = 'mw-ext-sp-iv';
38    private const CLAIM_TYPE_MAC = 'mw-ext-sp-mac';
39
40    /** @var Context|null */
41    private $context;
42
43    /** @var Election|null */
44    private $election;
45
46    /** @var OpenSSLAsymmetricKey|resource|null */
47    private $encryptKey = null;
48
49    /** @var OpenSSLAsymmetricKey|resource|null */
50    private $signKey = null;
51
52    /** @var OpenSSLAsymmetricKey|resource|null */
53    private $decryptKey = null;
54
55    /** @var OpenSSLAsymmetricKey|resource|null */
56    private $verifyKey = null;
57
58    /**
59     * Constructor.
60     * @param Context|null $context
61     * @param Election|null $election
62     */
63    public function __construct( $context, $election ) {
64        if ( !extension_loaded( 'openssl' ) ) {
65            throw new RuntimeException( 'The openssl extension must be enabled in php.ini to use this class' );
66        }
67
68        $this->context = $context;
69        $this->election = $election;
70    }
71
72    /**
73     * Encrypt some data. When successful, the value member of the Status object
74     * will contain the encrypted record.
75     * @param string $record
76     * @return Status
77     */
78    public function encrypt( $record ) {
79        $status = $this->setupKeys();
80        if ( !$status->isOK() ) {
81            $this->cleanup();
82            return $status;
83        }
84
85        if ( $this->encryptKey === null || $this->signKey === null ) {
86            return Status::newFatal( 'securepoll-openssl-invalid-key' );
87        }
88
89        $cipherAlg = 'aes-256-gcm';
90        $keyLength = 32;
91        $signAlg = 'RS256';
92        $hashAlg = 'sha256';
93
94        $this->clearErrors();
95        $ivLength = openssl_cipher_iv_length( $cipherAlg );
96        if ( $ivLength === false ) {
97            return $this->getErrorStatus( 'openssl_cipher_iv_length' );
98        }
99
100        $iv = random_bytes( $ivLength );
101        $tag = '';
102        $secretKey = random_bytes( $keyLength );
103
104        // authenticate vote metadata with our secret key, as otherwise in scenarios
105        // where the signing key is compromised or doesn't exist, the metadata could be forged/changed
106        $issuedBy = self::getIssuer();
107        $subject = (string)$this->election->getId();
108        $aad = $issuedBy . '|' . $subject;
109
110        $this->clearErrors();
111        $ciphertext = openssl_encrypt(
112            $record,
113            $cipherAlg,
114            $secretKey,
115            OPENSSL_RAW_DATA,
116            $iv,
117            $tag,
118            $aad,
119            16
120        );
121
122        if ( $ciphertext === false ) {
123            return $this->getErrorStatus( 'openssl_encrypt' );
124        }
125
126        $this->clearErrors();
127        $result = openssl_public_encrypt(
128            $secretKey,
129            $envelopeKey,
130            $this->encryptKey,
131            OPENSSL_PKCS1_OAEP_PADDING
132        );
133
134        if ( !$result ) {
135            return $this->getErrorStatus( 'openssl_public_encrypt' );
136        }
137
138        $header = [
139            self::CLAIM_TYPE_SIGNATURE_ALGORITHM => $signAlg,
140            self::CLAIM_TYPE_TOKEN_TYPE => 'JWT'
141        ];
142
143        $claims = [
144            self::CLAIM_TYPE_ISSUER => $issuedBy,
145            self::CLAIM_TYPE_SUBJECT => $subject,
146            self::CLAIM_TYPE_VOTE => base64_encode( $ciphertext ),
147            self::CLAIM_TYPE_ENCRYPT_ALGORITHM => $cipherAlg,
148            self::CLAIM_TYPE_ENVELOPE_KEY => base64_encode( $envelopeKey ),
149            self::CLAIM_TYPE_TAG => base64_encode( $tag ),
150            self::CLAIM_TYPE_IV => base64_encode( $iv ),
151            self::CLAIM_TYPE_MAC => hash_hmac( 'sha256', $envelopeKey, $secretKey ),
152        ];
153
154        $jwt = $this->jwtEncode( $header ) . '.' . $this->jwtEncode( $claims );
155        $this->clearErrors();
156        $result = openssl_sign( $jwt, $sig, $this->signKey, $hashAlg );
157        if ( !$result ) {
158            return $this->getErrorStatus( 'openssl_sign' );
159        }
160
161        $jwt .= '.' . $this->jwtEncode( $sig );
162
163        return Status::newGood( $jwt );
164    }
165
166    /**
167     * Decrypt some data. When successful, the value member of the Status object
168     * will contain the encrypted record.
169     *
170     * This may be run in an offline scenario with no access to the public encryption
171     * key or private signing key. This method requires the private decryption key and
172     * public verification key to be supplied via the decryption data.
173     *
174     * @param string $record
175     * @return Status
176     */
177    public function decrypt( $record ) {
178        $status = $this->setupKeys();
179        if ( !$status->isOK() ) {
180            $this->cleanup();
181            return $status;
182        }
183
184        if ( $this->decryptKey === null ) {
185            return Status::newFatal( 'securepoll-no-decryption-key' );
186        }
187
188        if ( $this->verifyKey === null ) {
189            return Status::newFatal( 'securepoll-no-verification-key' );
190        }
191
192        // $record may contain a leading line break in dump-based tallying, so trim it out before verifying the JWT
193        $parts = explode( '.', trim( $record ) );
194        if ( count( $parts ) !== 3 ) {
195            return $this->getErrorStatus( 'verify_jwt', 'jwt does not contain exactly 3 parts' );
196        }
197
198        try {
199            $header = $this->jwtDecode( $parts[0] );
200            $claims = $this->jwtDecode( $parts[1] );
201            $sig = $this->jwtDecode( $parts[2], false );
202        } catch ( JsonException $e ) {
203            return $this->getErrorStatus( 'verify_jwt', $e->getMessage() );
204        }
205
206        if ( $header[self::CLAIM_TYPE_SIGNATURE_ALGORITHM] === 'RS256' ) {
207            $hashAlg = 'sha256';
208        } else {
209            return $this->getErrorStatus( 'verify_jwt', 'jwt header alg is not RS256' );
210        }
211
212        $requiredClaims = [
213            self::CLAIM_TYPE_ISSUER,
214            self::CLAIM_TYPE_SUBJECT,
215            self::CLAIM_TYPE_VOTE,
216            self::CLAIM_TYPE_ENCRYPT_ALGORITHM,
217            self::CLAIM_TYPE_ENVELOPE_KEY,
218            self::CLAIM_TYPE_TAG,
219            self::CLAIM_TYPE_IV,
220            self::CLAIM_TYPE_MAC,
221        ];
222        foreach ( $requiredClaims as $claim ) {
223            if ( !isset( $claims[$claim] ) || !is_string( $claims[$claim] ) || $claims[$claim] === '' ) {
224                return $this->getErrorStatus( 'verify_jwt', "jwt missing claim $claim" );
225            }
226        }
227
228        $data = $parts[0] . '.' . $parts[1];
229        $this->clearErrors();
230        $result = openssl_verify( $data, $sig, $this->verifyKey, $hashAlg );
231        if ( $result === false || $result === -1 ) {
232            return $this->getErrorStatus( 'openssl_verify' );
233        } elseif ( $result === 0 ) {
234            return $this->getErrorStatus( 'verify_jwt', 'invalid signature' );
235        }
236
237        if ( $claims[self::CLAIM_TYPE_ISSUER] !== self::getIssuer()
238            || $claims[self::CLAIM_TYPE_SUBJECT] !== (string)$this->election->getId()
239        ) {
240            return $this->getErrorStatus( 'verify_jwt', 'jwt is not for the current election' );
241        }
242
243        if ( !in_array( $claims[self::CLAIM_TYPE_ENCRYPT_ALGORITHM], openssl_get_cipher_methods() ) ) {
244            return $this->getErrorStatus( 'decrypt_vote', 'vote encryption algorithm not supported' );
245        }
246
247        $decoded = [
248            self::CLAIM_TYPE_VOTE => false,
249            self::CLAIM_TYPE_ENVELOPE_KEY => false,
250            self::CLAIM_TYPE_IV => false,
251            self::CLAIM_TYPE_TAG => false
252        ];
253        '@phan-var array<string,string|false> $decoded';
254
255        foreach ( $decoded as $claim => &$value ) {
256            $value = base64_decode( $claims[$claim], true );
257            if ( $value === false ) {
258                return $this->getErrorStatus( 'decrypt_vote', "invalid base64 in $claim" );
259            }
260        }
261
262        $this->clearErrors();
263        $result = openssl_private_decrypt(
264            $decoded[self::CLAIM_TYPE_ENVELOPE_KEY],
265            $secretKey,
266            $this->decryptKey,
267            OPENSSL_PKCS1_OAEP_PADDING
268        );
269
270        if ( !$result ) {
271            return $this->getErrorStatus( 'openssl_private_decrypt' );
272        }
273
274        $mac = hash_hmac( 'sha256', $decoded[self::CLAIM_TYPE_ENVELOPE_KEY], $secretKey );
275        if ( !hash_equals( $claims[self::CLAIM_TYPE_MAC], $mac ) ) {
276            $this->cleanup();
277            return Status::newFatal( 'securepoll-wrong-decryption-key' );
278        }
279
280        $aad = $claims['iss'] . '|' . $claims['sub'];
281        $this->clearErrors();
282        $vote = openssl_decrypt(
283            $decoded[self::CLAIM_TYPE_VOTE],
284            $claims[self::CLAIM_TYPE_ENCRYPT_ALGORITHM],
285            $secretKey,
286            OPENSSL_RAW_DATA,
287            $decoded[self::CLAIM_TYPE_IV],
288            $decoded[self::CLAIM_TYPE_TAG],
289            $aad
290        );
291
292        if ( $vote === false ) {
293            return $this->getErrorStatus( 'openssl_decrypt' );
294        }
295
296        return Status::newGood( $vote );
297    }
298
299    /**
300     * @param string $prefix Prefix for error message
301     * @param ?string $error Error message, or null to use openssl_error_string()
302     * @return Status
303     */
304    private function getErrorStatus( string $prefix, ?string $error = null ): Status {
305        global $wgSecurePollShowErrorDetail;
306
307        $error ??= openssl_error_string();
308        $this->cleanup();
309        wfDebug( "$prefix:$error" );
310
311        if ( $wgSecurePollShowErrorDetail ) {
312            return Status::newFatal( 'securepoll-full-openssl-error', "$prefix:$error" );
313        } else {
314            return Status::newFatal( 'securepoll-secret-openssl-error' );
315        }
316    }
317
318    /**
319     * Load our keys from the election store.
320     * Keys that do not exist in the store will remain null, however if a private key is supplied
321     * but not its corresponding public key, we will derive it.
322     *
323     * @return Status
324     */
325    private function setupKeys(): Status {
326        if ( $this->decryptKey === null ) {
327            $decryptKey = $this->context->decryptData['openssl-decrypt-key'] ??
328                strval( $this->election->getProperty( 'openssl-decrypt-key' ) );
329            if ( $decryptKey !== '' ) {
330                $this->clearErrors();
331                $result = openssl_pkey_get_private( $decryptKey );
332                if ( $result === false ) {
333                    return $this->getErrorStatus( 'openssl_pkey_get_private' );
334                }
335
336                $this->decryptKey = $result;
337            }
338        }
339
340        if ( $this->encryptKey === null ) {
341            $encryptKey = strval( $this->election->getProperty( 'openssl-encrypt-key' ) );
342            if ( $encryptKey === '' && $this->decryptKey !== null ) {
343                $this->clearErrors();
344                $result = openssl_pkey_get_details( $this->decryptKey );
345                if ( $result === false ) {
346                    return $this->getErrorStatus( 'openssl_pkey_get_details' );
347                }
348
349                $encryptKey = $result['key'];
350            }
351
352            if ( $encryptKey !== '' ) {
353                $this->clearErrors();
354                $result = openssl_pkey_get_public( $encryptKey );
355                if ( $result === false ) {
356                    return $this->getErrorStatus( 'openssl_pkey_get_public' );
357                }
358
359                $this->encryptKey = $result;
360
361                $this->clearErrors();
362                $keyDetails = openssl_pkey_get_details( $this->encryptKey );
363                if ( $keyDetails === false ) {
364                    return $this->getErrorStatus( 'openssl_pkey_get_details' );
365                }
366
367                if ( $keyDetails['type'] !== OPENSSL_KEYTYPE_RSA ) {
368                    return $this->getErrorStatus( 'setup_keys', 'encryption key is not RSA' );
369                }
370
371                if ( $keyDetails['bits'] < 2048 ) {
372                    return $this->getErrorStatus( 'setup_keys', 'encryption key is weaker than 2048 bits' );
373                }
374            }
375        }
376
377        if ( $this->signKey === null ) {
378            $signKey = strval( $this->election->getProperty( 'openssl-sign-key' ) );
379            if ( $signKey !== '' ) {
380                $this->clearErrors();
381                $result = openssl_pkey_get_private( $signKey );
382                if ( $result === false ) {
383                    return $this->getErrorStatus( 'openssl_pkey_get_private' );
384                }
385
386                $this->signKey = $result;
387
388                $this->clearErrors();
389                $keyDetails = openssl_pkey_get_details( $this->signKey );
390                if ( $keyDetails === false ) {
391                    return $this->getErrorStatus( 'openssl_pkey_get_details' );
392                }
393
394                if ( $keyDetails['type'] !== OPENSSL_KEYTYPE_RSA ) {
395                    return $this->getErrorStatus( 'setup_keys', 'signing key is not RSA' );
396                }
397
398                if ( $keyDetails['bits'] < 2048 ) {
399                    return $this->getErrorStatus( 'setup_keys', 'signing key is weaker than 2048 bits' );
400                }
401            }
402        }
403
404        if ( $this->verifyKey === null ) {
405            $verifyKey = $this->context->decryptData['openssl-verify-key'] ??
406                strval( $this->election->getProperty( 'openssl-verify-key' ) );
407            if ( $verifyKey === '' && $this->signKey !== null ) {
408                $this->clearErrors();
409                $result = openssl_pkey_get_details( $this->signKey );
410                if ( $result === false ) {
411                    return $this->getErrorStatus( 'openssl_pkey_get_details' );
412                }
413
414                $verifyKey = $result['key'];
415            }
416
417            if ( $verifyKey !== '' ) {
418                $this->clearErrors();
419                $result = openssl_pkey_get_public( $verifyKey );
420                if ( $result === false ) {
421                    return $this->getErrorStatus( 'openssl_pkey_get_public' );
422                }
423
424                $this->verifyKey = $result;
425            }
426        }
427
428        return Status::newGood();
429    }
430
431    /**
432     * Clears the openssl error string queue.
433     * Some successful openssl operations still append to this queue, so it must be
434     * cleared before running operations which may fail and which we want failure details for.
435     *
436     * @return void
437     */
438    private function clearErrors(): void {
439        while ( true ) {
440            $error = openssl_error_string();
441            if ( !$error ) {
442                break;
443            }
444        }
445    }
446
447    /**
448     * Encode data using base64 url-safe variant
449     *
450     * @param array|string $data
451     * @return string
452     */
453    private function jwtEncode( $data ): string {
454        if ( is_array( $data ) ) {
455            $data = json_encode( $data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
456        }
457
458        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
459    }
460
461    /**
462     * Decode data using base64 url-safe variant
463     *
464     * @param string $data
465     * @param bool $parseJson If true, parse result as JSON
466     * @return array|string Array if $parseJson is true, string otherwise
467     */
468    private function jwtDecode( string $data, $parseJson = true ) {
469        $data = base64_decode( strtr( $data, '-_', '+/' ) );
470
471        if ( $parseJson ) {
472            return json_decode( $data, true, 4, JSON_THROW_ON_ERROR );
473        } else {
474            return $data;
475        }
476    }
477
478    public function cleanup() {
479        $this->encryptKey = null;
480        $this->signKey = null;
481        $this->decryptKey = null;
482        $this->verifyKey = null;
483        $this->clearErrors();
484    }
485
486    public function canDecrypt() {
487        $decryptKey = strval( $this->election->getProperty( 'openssl-decrypt-key' ) );
488
489        return $decryptKey !== '';
490    }
491
492    /**
493     * Return descriptors for any properties this type requires for poll
494     * creation, for the election, questions, and options.
495     *
496     * The returned array should have three keys, "election", "question", and
497     * "option", each mapping to an array of HTMLForm descriptors.
498     *
499     * The descriptors should have an additional key, "SecurePoll_type", with
500     * the value being "property" or "message".
501     *
502     * @return array
503     */
504    public static function getCreateDescriptors() {
505        global $wgSecurePollOpenSslSignKey;
506
507        $ret = parent::getCreateDescriptors();
508
509        $ret['election'] += [
510            'openssl-encrypt-key' => [
511                'label-message' => 'securepoll-create-label-openssl_encrypt_key',
512                'type' => 'textarea',
513                'SecurePoll_type' => 'property',
514                'rows' => 5,
515                'validation-callback' => static function ( string $key ) {
516                    return self::checkPublicKey( $key );
517                },
518            ]
519        ];
520
521        if ( $wgSecurePollOpenSslSignKey ) {
522            $ret['election'] += [
523                'openssl-sign-key' => [
524                    'type' => 'api',
525                    'default' => $wgSecurePollOpenSslSignKey,
526                    'SecurePoll_type' => 'property',
527                ]
528            ];
529        } else {
530            $ret['election'] += [
531                'openssl-sign-key' => [
532                    'label-message' => 'securepoll-create-label-openssl_sign_key',
533                    'type' => 'textarea',
534                    'SecurePoll_type' => 'property',
535                    'rows' => 5,
536                    'validation-callback' => static function ( string $key ) {
537                        return self::checkPrivateKey( $key );
538                    },
539                ]
540            ];
541        }
542
543        return $ret;
544    }
545
546    public function getTallyDescriptors(): array {
547        $verifyKeyRequired = strval( $this->election->getProperty( 'openssl-sign-key' ) ) === '';
548
549        return [
550            'openssl-decrypt-key' => [
551                'label-message' => 'securepoll-tally-openssl-decrypt-key',
552                'type' => 'textarea',
553                'required' => true,
554                'rows' => 5,
555                'validation-callback' => static function ( string $key ) {
556                    return self::checkPrivateKey( $key );
557                },
558            ],
559            'openssl-verify-key' => [
560                'label-message' => 'securepoll-tally-openssl-verify-key',
561                'type' => 'textarea',
562                'required' => $verifyKeyRequired,
563                'rows' => 5,
564                'validation-callback' => static function ( string $key ) use ( $verifyKeyRequired ) {
565                    return self::checkPublicKey( $key, $verifyKeyRequired );
566                },
567            ],
568        ];
569    }
570
571    /**
572     * Check validity of an encryption key
573     *
574     * @param string $key
575     * @param bool $required If the key is required to be provided
576     * @return Message|true
577     */
578    private static function checkPublicKey( string $key, bool $required = true ) {
579        if ( $key === '' ) {
580            if ( $required ) {
581                return Status::newFatal( 'htmlform-required' )->getMessage();
582            } else {
583                // not required so we're fine with this being empty
584                return true;
585            }
586        }
587
588        return self::checkKeyInternal( openssl_pkey_get_public( $key ) );
589    }
590
591    /**
592     * Check validity of a decryption or signing key
593     * @param string $key
594     * @return Message|true
595     */
596    public static function checkPrivateKey( string $key ) {
597        if ( $key === '' ) {
598            return Status::newFatal( 'htmlform-required' )->getMessage();
599        }
600
601        return self::checkKeyInternal( openssl_pkey_get_private( $key ) );
602    }
603
604    /**
605     * Internal validation routine for public or private keys
606     *
607     * @param OpenSSLAsymmetricKey|resource|false $key
608     * @return Message|true
609     */
610    private static function checkKeyInternal( $key ) {
611        if ( $key === false ) {
612            return Status::newFatal( 'securepoll-openssl-invalid-key' )->getMessage();
613        }
614
615        $result = openssl_pkey_get_details( $key );
616        if ( $result === false || $result['type'] !== OPENSSL_KEYTYPE_RSA || $result['bits'] < 2048 ) {
617            return Status::newFatal( 'securepoll-openssl-invalid-key' )->getMessage();
618        }
619
620        return true;
621    }
622
623    public function updateTallyContext( Context $context, array $data ): void {
624        // no-op
625    }
626
627    public function updateDbForTallyJob( int $electionId, IDatabase $dbw, array $data ): void {
628        // Add private key to DB if it was entered in the form
629        if ( isset( $data['openssl-decrypt-key'] ) ) {
630            $dbw->newReplaceQueryBuilder()
631                ->replaceInto( 'securepoll_properties' )
632                ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
633                ->row( [
634                    'pr_entity' => $electionId,
635                    'pr_key' => 'openssl-decrypt-key',
636                    'pr_value' => $data['openssl-decrypt-key']
637                ] )
638                ->caller( __METHOD__ )->execute();
639        }
640    }
641
642    public function cleanupDbForTallyJob( int $electionId, IDatabase $dbw ): void {
643        $dbw->newDeleteQueryBuilder()
644            ->deleteFrom( 'securepoll_properties' )
645            ->where( [
646                'pr_entity' => $electionId,
647                'pr_key' => 'openssl-decrypt-key',
648            ] )
649            ->caller( __METHOD__ )->execute();
650    }
651
652    /**
653     * Retrieve the URL of Special:SecurePoll, for validating JWTs.
654     *
655     * @return string
656     */
657    private static function getIssuer(): string {
658        return SpecialPage::getTitleFor( 'SecurePoll' )->getCanonicalURL();
659    }
660}