Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.66% covered (warning)
61.66%
201 / 326
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenSslCrypt
61.66% covered (warning)
61.66%
201 / 326
31.58% covered (danger)
31.58%
6 / 19
513.69
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
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 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 / 36
0.00% covered (danger)
0.00%
0 / 1
12
 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\MediaWikiServices;
9use MediaWiki\Message\Message;
10use MediaWiki\SpecialPage\SpecialPage;
11use MediaWiki\Status\Status;
12use OpenSSLAsymmetricKey;
13use RuntimeException;
14use Wikimedia\Rdbms\IDatabase;
15
16/**
17 * Cryptography module that uses the PHP openssl extension.
18 * At the moment, only RSA keys are supported, with a minimum size of 2048 bits.
19 *
20 * Election properties used:
21 *     openssl-encrypt-key:  The public key used for encrypting.
22 *     openssl-sign-key:     The private key used for signing.
23 *     openssl-decrypt-key:  The private key used for decrypting.
24 *     openssl-verify-key:   The public ky used for verification.
25 *
26 * Generally only openssl-encrypt-key and openssl-sign-key are required for voting,
27 * openssl-decrypt-key and openssl-verify-key are for tallying.
28 */
29class OpenSslCrypt extends Crypt {
30    private const CLAIM_TYPE_TOKEN_TYPE = 'typ';
31    private const CLAIM_TYPE_SIGNATURE_ALGORITHM = 'alg';
32    private const CLAIM_TYPE_ISSUER = 'iss';
33    private const CLAIM_TYPE_SUBJECT = 'sub';
34    private const CLAIM_TYPE_VOTE = 'mw-ext-sp-vot';
35    private const CLAIM_TYPE_ENCRYPT_ALGORITHM = 'mw-ext-sp-alg';
36    private const CLAIM_TYPE_ENVELOPE_KEY = 'mw-ext-sp-env';
37    private const CLAIM_TYPE_TAG = 'mw-ext-sp-tag';
38    private const CLAIM_TYPE_IV = 'mw-ext-sp-iv';
39    private const CLAIM_TYPE_MAC = 'mw-ext-sp-mac';
40
41    /** @var Context|null */
42    private $context;
43
44    /** @var Election|null */
45    private $election;
46
47    /** @var OpenSSLAsymmetricKey|resource|null */
48    private $encryptKey = null;
49
50    /** @var OpenSSLAsymmetricKey|resource|null */
51    private $signKey = null;
52
53    /** @var OpenSSLAsymmetricKey|resource|null */
54    private $decryptKey = null;
55
56    /** @var OpenSSLAsymmetricKey|resource|null */
57    private $verifyKey = null;
58
59    /**
60     * Constructor.
61     * @param Context|null $context
62     * @param Election|null $election
63     */
64    public function __construct( $context, $election ) {
65        if ( !extension_loaded( 'openssl' ) ) {
66            throw new RuntimeException( 'The openssl extension must be enabled in php.ini to use this class' );
67        }
68
69        $this->context = $context;
70        $this->election = $election;
71    }
72
73    /**
74     * Encrypt some data. When successful, the value member of the Status object
75     * will contain the encrypted record.
76     * @param string $record
77     * @return Status
78     */
79    public function encrypt( $record ) {
80        $status = $this->setupKeys();
81        if ( !$status->isOK() ) {
82            $this->cleanup();
83            return $status;
84        }
85
86        if ( $this->encryptKey === null || $this->signKey === null ) {
87            return Status::newFatal( 'securepoll-openssl-invalid-key' );
88        }
89
90        $cipherAlg = 'aes-256-gcm';
91        $keyLength = 32;
92        $signAlg = 'RS256';
93        $hashAlg = 'sha256';
94
95        $this->clearErrors();
96        $ivLength = openssl_cipher_iv_length( $cipherAlg );
97        if ( $ivLength === false ) {
98            return $this->getErrorStatus( 'openssl_cipher_iv_length' );
99        }
100
101        $iv = random_bytes( $ivLength );
102        $tag = '';
103        $secretKey = random_bytes( $keyLength );
104
105        // authenticate vote metadata with our secret key, as otherwise in scenarios
106        // where the signing key is compromised or doesn't exist, the metadata could be forged/changed
107        $issuedBy = self::getIssuer();
108        $subject = (string)$this->election->getId();
109        $aad = $issuedBy . '|' . $subject;
110
111        $this->clearErrors();
112        $ciphertext = openssl_encrypt(
113            $record,
114            $cipherAlg,
115            $secretKey,
116            OPENSSL_RAW_DATA,
117            $iv,
118            $tag,
119            $aad,
120            16
121        );
122
123        if ( $ciphertext === false ) {
124            return $this->getErrorStatus( 'openssl_encrypt' );
125        }
126
127        $this->clearErrors();
128        $result = openssl_public_encrypt(
129            $secretKey,
130            $envelopeKey,
131            $this->encryptKey,
132            OPENSSL_PKCS1_OAEP_PADDING
133        );
134
135        if ( !$result ) {
136            return $this->getErrorStatus( 'openssl_public_encrypt' );
137        }
138
139        $header = [
140            self::CLAIM_TYPE_SIGNATURE_ALGORITHM => $signAlg,
141            self::CLAIM_TYPE_TOKEN_TYPE => 'JWT'
142        ];
143
144        $claims = [
145            self::CLAIM_TYPE_ISSUER => $issuedBy,
146            self::CLAIM_TYPE_SUBJECT => $subject,
147            self::CLAIM_TYPE_VOTE => base64_encode( $ciphertext ),
148            self::CLAIM_TYPE_ENCRYPT_ALGORITHM => $cipherAlg,
149            self::CLAIM_TYPE_ENVELOPE_KEY => base64_encode( $envelopeKey ),
150            self::CLAIM_TYPE_TAG => base64_encode( $tag ),
151            self::CLAIM_TYPE_IV => base64_encode( $iv ),
152            self::CLAIM_TYPE_MAC => hash_hmac( 'sha256', $envelopeKey, $secretKey ),
153        ];
154
155        $jwt = $this->jwtEncode( $header ) . '.' . $this->jwtEncode( $claims );
156        $this->clearErrors();
157        $result = openssl_sign( $jwt, $sig, $this->signKey, $hashAlg );
158        if ( !$result ) {
159            return $this->getErrorStatus( 'openssl_sign' );
160        }
161
162        $jwt .= '.' . $this->jwtEncode( $sig );
163
164        return Status::newGood( $jwt );
165    }
166
167    /**
168     * Decrypt some data. When successful, the value member of the Status object
169     * will contain the encrypted record.
170     *
171     * This may be run in an offline scenario with no access to the public encryption
172     * key or private signing key. This method requires the private decryption key and
173     * public verification key to be supplied via the decryption data.
174     *
175     * @param string $record
176     * @return Status
177     */
178    public function decrypt( $record ) {
179        $status = $this->setupKeys();
180        if ( !$status->isOK() ) {
181            $this->cleanup();
182            return $status;
183        }
184
185        if ( $this->decryptKey === null ) {
186            return Status::newFatal( 'securepoll-no-decryption-key' );
187        }
188
189        if ( $this->verifyKey === null ) {
190            return Status::newFatal( 'securepoll-no-verification-key' );
191        }
192
193        // $record may contain a leading line break in dump-based tallying, so trim it out before verifying the JWT
194        $parts = explode( '.', trim( $record ) );
195        if ( count( $parts ) !== 3 ) {
196            return $this->getErrorStatus( 'verify_jwt', 'jwt does not contain exactly 3 parts' );
197        }
198
199        try {
200            $header = $this->jwtDecode( $parts[0] );
201            $claims = $this->jwtDecode( $parts[1] );
202            $sig = $this->jwtDecode( $parts[2], false );
203        } catch ( JsonException $e ) {
204            return $this->getErrorStatus( 'verify_jwt', $e->getMessage() );
205        }
206
207        if ( $header[self::CLAIM_TYPE_SIGNATURE_ALGORITHM] === 'RS256' ) {
208            $hashAlg = 'sha256';
209        } else {
210            return $this->getErrorStatus( 'verify_jwt', 'jwt header alg is not RS256' );
211        }
212
213        $requiredClaims = [
214            self::CLAIM_TYPE_ISSUER,
215            self::CLAIM_TYPE_SUBJECT,
216            self::CLAIM_TYPE_VOTE,
217            self::CLAIM_TYPE_ENCRYPT_ALGORITHM,
218            self::CLAIM_TYPE_ENVELOPE_KEY,
219            self::CLAIM_TYPE_TAG,
220            self::CLAIM_TYPE_IV,
221            self::CLAIM_TYPE_MAC,
222        ];
223        foreach ( $requiredClaims as $claim ) {
224            if ( !isset( $claims[$claim] ) || !is_string( $claims[$claim] ) || $claims[$claim] === '' ) {
225                return $this->getErrorStatus( 'verify_jwt', "jwt missing claim $claim" );
226            }
227        }
228
229        $data = $parts[0] . '.' . $parts[1];
230        $this->clearErrors();
231        $result = openssl_verify( $data, $sig, $this->verifyKey, $hashAlg );
232        if ( $result === false || $result === -1 ) {
233            return $this->getErrorStatus( 'openssl_verify' );
234        } elseif ( $result === 0 ) {
235            return $this->getErrorStatus( 'verify_jwt', 'invalid signature' );
236        }
237
238        if ( $claims[self::CLAIM_TYPE_ISSUER] !== self::getIssuer()
239            || $claims[self::CLAIM_TYPE_SUBJECT] !== (string)$this->election->getId()
240        ) {
241            return $this->getErrorStatus( 'verify_jwt', 'jwt is not for the current election' );
242        }
243
244        if ( !in_array( $claims[self::CLAIM_TYPE_ENCRYPT_ALGORITHM], openssl_get_cipher_methods() ) ) {
245            return $this->getErrorStatus( 'decrypt_vote', 'vote encryption algorithm not supported' );
246        }
247
248        $decoded = [
249            self::CLAIM_TYPE_VOTE => false,
250            self::CLAIM_TYPE_ENVELOPE_KEY => false,
251            self::CLAIM_TYPE_IV => false,
252            self::CLAIM_TYPE_TAG => false
253        ];
254        '@phan-var array<string,string|false> $decoded';
255
256        foreach ( $decoded as $claim => &$value ) {
257            $value = base64_decode( $claims[$claim], true );
258            if ( $value === false ) {
259                return $this->getErrorStatus( 'decrypt_vote', "invalid base64 in $claim" );
260            }
261        }
262
263        $this->clearErrors();
264        $result = openssl_private_decrypt(
265            $decoded[self::CLAIM_TYPE_ENVELOPE_KEY],
266            $secretKey,
267            $this->decryptKey,
268            OPENSSL_PKCS1_OAEP_PADDING
269        );
270
271        if ( !$result ) {
272            return $this->getErrorStatus( 'openssl_private_decrypt' );
273        }
274
275        $mac = hash_hmac( 'sha256', $decoded[self::CLAIM_TYPE_ENVELOPE_KEY], $secretKey );
276        if ( !hash_equals( $claims[self::CLAIM_TYPE_MAC], $mac ) ) {
277            $this->cleanup();
278            return Status::newFatal( 'securepoll-wrong-decryption-key' );
279        }
280
281        $aad = $claims['iss'] . '|' . $claims['sub'];
282        $this->clearErrors();
283        $vote = openssl_decrypt(
284            $decoded[self::CLAIM_TYPE_VOTE],
285            $claims[self::CLAIM_TYPE_ENCRYPT_ALGORITHM],
286            $secretKey,
287            OPENSSL_RAW_DATA,
288            $decoded[self::CLAIM_TYPE_IV],
289            $decoded[self::CLAIM_TYPE_TAG],
290            $aad
291        );
292
293        if ( $vote === false ) {
294            return $this->getErrorStatus( 'openssl_decrypt' );
295        }
296
297        return Status::newGood( $vote );
298    }
299
300    /**
301     * @param string $prefix Prefix for error message
302     * @param ?string $error Error message, or null to use openssl_error_string()
303     * @return Status
304     */
305    private function getErrorStatus( string $prefix, ?string $error = null ): Status {
306        $config = MediaWikiServices::getInstance()->getMainConfig();
307        $error ??= openssl_error_string();
308        $this->cleanup();
309        wfDebug( "$prefix:$error" );
310
311        if ( $config->get( 'SecurePollShowErrorDetail' ) ) {
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        $config = MediaWikiServices::getInstance()->getMainConfig();
506        $openSslSignKey = $config->has( 'SecurePollOpenSslSignKey' )
507            ? $config->get( 'SecurePollOpenSslSignKey' )
508            : false;
509
510        $ret = parent::getCreateDescriptors();
511
512        $ret['election'] += [
513            'openssl-encrypt-key' => [
514                'label-message' => 'securepoll-create-label-openssl_encrypt_key',
515                'type' => 'textarea',
516                'SecurePoll_type' => 'property',
517                'rows' => 5,
518                'validation-callback' => static function ( string $key ) {
519                    return self::checkPublicKey( $key );
520                },
521            ]
522        ];
523
524        if ( $openSslSignKey ) {
525            $ret['election'] += [
526                'openssl-sign-key' => [
527                    'type' => 'api',
528                    'default' => $openSslSignKey,
529                    'SecurePoll_type' => 'property',
530                ]
531            ];
532        } else {
533            $ret['election'] += [
534                'openssl-sign-key' => [
535                    'label-message' => 'securepoll-create-label-openssl_sign_key',
536                    'type' => 'textarea',
537                    'SecurePoll_type' => 'property',
538                    'rows' => 5,
539                    'validation-callback' => static function ( string $key ) {
540                        return self::checkPrivateKey( $key );
541                    },
542                ]
543            ];
544        }
545
546        return $ret;
547    }
548
549    public function getTallyDescriptors(): array {
550        $verifyKeyRequired = strval( $this->election->getProperty( 'openssl-sign-key' ) ) === '';
551
552        return [
553            'openssl-decrypt-key' => [
554                'label-message' => 'securepoll-tally-openssl-decrypt-key',
555                'type' => 'textarea',
556                'required' => true,
557                'rows' => 5,
558                'validation-callback' => static function ( string $key ) {
559                    return self::checkPrivateKey( $key );
560                },
561            ],
562            'openssl-verify-key' => [
563                'label-message' => 'securepoll-tally-openssl-verify-key',
564                'type' => 'textarea',
565                'required' => $verifyKeyRequired,
566                'rows' => 5,
567                'validation-callback' => static function ( string $key ) use ( $verifyKeyRequired ) {
568                    return self::checkPublicKey( $key, $verifyKeyRequired );
569                },
570            ],
571        ];
572    }
573
574    /**
575     * Check validity of an encryption key
576     *
577     * @param string $key
578     * @param bool $required If the key is required to be provided
579     * @return Message|true
580     */
581    private static function checkPublicKey( string $key, bool $required = true ) {
582        if ( $key === '' ) {
583            if ( $required ) {
584                return Status::newFatal( 'htmlform-required' )->getMessage();
585            } else {
586                // not required so we're fine with this being empty
587                return true;
588            }
589        }
590
591        return self::checkKeyInternal( openssl_pkey_get_public( $key ) );
592    }
593
594    /**
595     * Check validity of a decryption or signing key
596     * @param string $key
597     * @return Message|true
598     */
599    public static function checkPrivateKey( string $key ) {
600        if ( $key === '' ) {
601            return Status::newFatal( 'htmlform-required' )->getMessage();
602        }
603
604        return self::checkKeyInternal( openssl_pkey_get_private( $key ) );
605    }
606
607    /**
608     * Internal validation routine for public or private keys
609     *
610     * @param OpenSSLAsymmetricKey|resource|false $key
611     * @return Message|true
612     */
613    private static function checkKeyInternal( $key ) {
614        if ( $key === false ) {
615            return Status::newFatal( 'securepoll-openssl-invalid-key' )->getMessage();
616        }
617
618        $result = openssl_pkey_get_details( $key );
619        if ( $result === false || $result['type'] !== OPENSSL_KEYTYPE_RSA || $result['bits'] < 2048 ) {
620            return Status::newFatal( 'securepoll-openssl-invalid-key' )->getMessage();
621        }
622
623        return true;
624    }
625
626    public function updateTallyContext( Context $context, array $data ): void {
627        // no-op
628    }
629
630    public function updateDbForTallyJob( int $electionId, IDatabase $dbw, array $data ): void {
631        // Add private key to DB if it was entered in the form
632        if ( isset( $data['openssl-decrypt-key'] ) ) {
633            $dbw->newReplaceQueryBuilder()
634                ->replaceInto( 'securepoll_properties' )
635                ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
636                ->row( [
637                    'pr_entity' => $electionId,
638                    'pr_key' => 'openssl-decrypt-key',
639                    'pr_value' => $data['openssl-decrypt-key']
640                ] )
641                ->caller( __METHOD__ )->execute();
642        }
643    }
644
645    public function cleanupDbForTallyJob( int $electionId, IDatabase $dbw ): void {
646        $dbw->newDeleteQueryBuilder()
647            ->deleteFrom( 'securepoll_properties' )
648            ->where( [
649                'pr_entity' => $electionId,
650                'pr_key' => 'openssl-decrypt-key',
651            ] )
652            ->caller( __METHOD__ )->execute();
653    }
654
655    /**
656     * Retrieve the URL of Special:SecurePoll, for validating JWTs.
657     *
658     * @return string
659     */
660    private static function getIssuer(): string {
661        return SpecialPage::getTitleFor( 'SecurePoll' )->getCanonicalURL();
662    }
663}