Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
62.31% |
200 / 321 |
|
31.58% |
6 / 19 |
CRAP | |
0.00% |
0 / 1 |
OpenSslCrypt | |
62.31% |
200 / 321 |
|
31.58% |
6 / 19 |
482.13 | |
0.00% |
0 / 1 |
__construct | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
encrypt | |
92.06% |
58 / 63 |
|
0.00% |
0 / 1 |
8.03 | |||
decrypt | |
77.78% |
63 / 81 |
|
0.00% |
0 / 1 |
27.31 | |||
getErrorStatus | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
setupKeys | |
82.54% |
52 / 63 |
|
0.00% |
0 / 1 |
28.33 | |||
clearErrors | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
jwtEncode | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
jwtDecode | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
cleanup | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
canDecrypt | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getCreateDescriptors | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
6 | |||
getTallyDescriptors | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
checkPublicKey | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
checkPrivateKey | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
checkKeyInternal | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
30 | |||
updateTallyContext | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
updateDbForTallyJob | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
cleanupDbForTallyJob | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getIssuer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Crypt; |
4 | |
5 | use JsonException; |
6 | use MediaWiki\Extension\SecurePoll\Context; |
7 | use MediaWiki\Extension\SecurePoll\Entities\Election; |
8 | use MediaWiki\Message\Message; |
9 | use MediaWiki\SpecialPage\SpecialPage; |
10 | use MediaWiki\Status\Status; |
11 | use OpenSSLAsymmetricKey; |
12 | use RuntimeException; |
13 | use 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 | */ |
28 | class 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 | } |