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\SpecialPage\SpecialPage; |
9 | use MediaWiki\Status\Status; |
10 | use Message; |
11 | use RuntimeException; |
12 | use 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 | */ |
27 | class 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 | } |