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