Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
61.66% |
201 / 326 |
|
31.58% |
6 / 19 |
CRAP | |
0.00% |
0 / 1 |
OpenSslCrypt | |
61.66% |
201 / 326 |
|
31.58% |
6 / 19 |
513.69 | |
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 | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
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 / 36 |
|
0.00% |
0 / 1 |
12 | |||
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\MediaWikiServices; |
9 | use MediaWiki\Message\Message; |
10 | use MediaWiki\SpecialPage\SpecialPage; |
11 | use MediaWiki\Status\Status; |
12 | use OpenSSLAsymmetricKey; |
13 | use RuntimeException; |
14 | use 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 | */ |
29 | class 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 | } |