Pluginsmime::getGABUser()   A
last analyzed

Complexity

Conditions 2
Paths 3

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 9
nc 3
nop 1
dl 0
loc 14
rs 9.9666
c 0
b 0
f 0
1
<?php
2
3
include_once 'util.php';
4
require_once 'class.certificate.php';
5
6
// Green, everything was good
7
define('SMIME_STATUS_SUCCESS', 0);
8
// Orange, CA is missing or OCSP is not available
9
define('SMIME_STATUS_PARTIAL', 1);
10
// Red, something really went wrong
11
define('SMIME_STATUS_FAIL', 2);
12
// Blue, info message
13
define('SMIME_STATUS_INFO', 3);
14
15
define('SMIME_SUCCESS', 0);
16
define('SMIME_NOPUB', 1);
17
define('SMIME_CERT_EXPIRED', 2);
18
define('SMIME_ERROR', 3);
19
define('SMIME_REVOKED', 4);
20
define('SMIME_CA', 5);
21
define('SMIME_DECRYPT_SUCCESS', 6);
22
define('SMIME_DECRYPT_FAILURE', 7);
23
define('SMIME_UNLOCK_CERT', 8);
24
define('SMIME_OCSP_NOSUPPORT', 9);
25
define('SMIME_OCSP_DISABLED', 10);
26
define('SMIME_OCSP_FAILED', 11);
27
define('SMIME_DECRYPT_CERT_MISMATCH', 12);
28
define('SMIME_USER_DETECT_FAILURE', 13);
29
30
// OpenSSL Error Constants
31
// openssl_error_string() returns error codes when an operation fails, since we return custom error strings
32
// in our plugin we keep a list of openssl error codes in these defines
33
define('OPENSSL_CA_VERIFY_FAIL', '21075075');
34
define('OPENSSL_RECIPIENT_CERTIFICATE_MISMATCH', '21070073');
35
36
class Pluginsmime extends Plugin {
37
	/**
38
	 * decrypted/verified message.
39
	 */
40
	private $message = [];
41
42
	/**
43
	 * Default MAPI Message Store.
44
	 */
45
	private $store;
46
47
	/**
48
	 * Last openssl error string.
49
	 */
50
	private $openssl_error = "";
51
52
	/**
53
	 * Cipher to use.
54
	 */
55
	private $cipher = PLUGIN_SMIME_CIPHER;
56
57
	/**
58
	 * Called to initialize the plugin and register for hooks.
59
	 */
60
	public function init() {
61
		$this->registerHook('server.core.settings.init.before');
62
		$this->registerHook('server.util.parse_smime.signed');
63
		$this->registerHook('server.util.parse_smime.encrypted');
64
		$this->registerHook('server.module.itemmodule.open.after');
65
		$this->registerHook('server.core.operations.submitmessage');
66
		$this->registerHook('server.upload_attachment.upload');
67
		$this->registerHook('server.module.createmailitemmodule.beforesend');
68
		$this->registerHook('server.index.load.custom');
69
70
		if (version_compare(phpversion(), '5.4', '<')) {
71
			$this->cipher = OPENSSL_CIPHER_AES_256_CBC;
72
		}
73
	}
74
75
	/**
76
	 * Default message store.
77
	 *
78
	 * @return object MAPI Message store
79
	 */
80
	public function getStore() {
81
		if (!$this->store) {
82
			$this->store = $GLOBALS['mapisession']->getDefaultMessageStore();
83
		}
84
85
		return $this->store;
86
	}
87
88
	/**
89
	 * Process the incoming events that where fired by the client.
90
	 *
91
	 * @param string $eventID Identifier of the hook
92
	 * @param array  $data    Reference to the data of the triggered hook
93
	 */
94
	public function execute($eventID, &$data) {
95
		switch ($eventID) {
96
			// Register plugin
97
			case 'server.core.settings.init.before':
98
				$this->onBeforeSettingsInit($data);
99
				break;
100
101
				// Verify a signed or encrypted message when an email is opened
102
			case 'server.util.parse_smime.signed':
103
				$this->onSignedMessage($data);
104
				break;
105
106
			case 'server.util.parse_smime.encrypted':
107
				$this->onEncrypted($data);
108
				break;
109
110
				// Add S/MIME property, which is send to the client
111
			case 'server.module.itemmodule.open.after':
112
				$this->onAfterOpen($data);
113
				break;
114
115
				// Catch uploaded certificate
116
			case 'server.upload_attachment.upload':
117
				$this->onUploadCertificate($data);
118
				break;
119
120
				// Sign email before sending
121
			case 'server.core.operations.submitmessage':
122
				$this->onBeforeSend($data);
123
				break;
124
125
				// Verify that we have public certificates for all recipients
126
			case 'server.module.createmailitemmodule.beforesend':
127
				$this->onCertificateCheck($data);
128
				break;
129
130
			case 'server.index.load.custom':
131
				if ($data['name'] === 'smime_passphrase') {
132
					include 'templates/passphrase.tpl.php';
133
134
					exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
135
				}
136
				if ($data['name'] === 'smime_passphrasecheck') {
137
					// No need to do anything, this is just used to trigger
138
					// the browser's autofill save password dialog.
139
					exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
140
				}
141
				break;
142
		}
143
	}
144
145
	/**
146
	 * Function checks if public certificate exists for all recipients and creates an error
147
	 * message for the frontend which includes the email address of the missing public
148
	 * certificates.
149
	 *
150
	 * If my own certificate is missing, a different error message is shown which informs the
151
	 * user that his own public certificate is missing and required for reading encrypted emails
152
	 * in the 'Sent items' folder.
153
	 *
154
	 * @param array $data Reference to the data of the triggered hook
155
	 */
156
	public function onCertificateCheck($data) {
157
		$entryid = $data['entryid'];
158
		// FIXME: unittests, save trigger will pass $entryid is 0 (which will open the root folder and not the message we want)
159
		if ($entryid === false) {
160
			return;
161
		}
162
163
		if (!isset($data['action']['props']['smime']) || empty($data['action']['props']['smime'])) {
164
			return;
165
		}
166
167
		$message = mapi_msgstore_openentry($data['store'], $entryid);
168
		$module = $data['moduleObject'];
169
		$data['success'] = true;
170
171
		$messageClass = mapi_getprops($message, [PR_MESSAGE_CLASS]);
172
		$messageClass = $messageClass[PR_MESSAGE_CLASS];
173
		if ($messageClass !== 'IPM.Note.SMIME' &&
174
			$messageClass !== 'IPM.Note.SMIME.SignedEncrypt' &&
175
			$messageClass !== 'IPM.Note.deferSMIME' &&
176
			$messageClass !== 'IPM.Note.deferSMIME.SignedEncrypt') {
177
			return;
178
		}
179
180
		$recipients = $data['action']['props']['smime'];
181
		$missingCerts = [];
182
183
		foreach ($recipients as $recipient) {
184
			$email = $recipient['email'];
185
186
			if (!$this->pubcertExists($email, $recipient['internal'])) {
187
				array_push($missingCerts, $email);
188
			}
189
		}
190
191
		if (empty($missingCerts)) {
192
			return;
193
		}
194
195
		function missingMyself($email) {
196
			return $GLOBALS['mapisession']->getSMTPAddress() === $email;
197
		}
198
199
		if (array_filter($missingCerts, "missingMyself") === []) {
200
			$errorMsg = _('Missing public certificates for the following recipients: ') . implode(', ', $missingCerts) . _('. Please contact your system administrator for details');
201
		}
202
		else {
203
			$errorMsg = _("Your public certificate is not installed. Without this certificate, you will not be able to read encrypted messages you have sent to others.");
204
		}
205
206
		$module->sendFeedback(false, ["type" => ERROR_GENERAL, "info" => ['display_message' => $errorMsg]]);
207
		$data['success'] = false;
208
	}
209
210
	/**
211
	 * Function which verifies a message.
212
	 *
213
	 * TODO: Clean up flow
214
	 *
215
	 * @param mixed $message
216
	 * @param mixed $eml
217
	 */
218
	public function verifyMessage($message, $eml) {
219
		$userProps = mapi_getprops($message, [PR_SENT_REPRESENTING_ENTRYID, PR_SENT_REPRESENTING_NAME]);
220
		$tmpUserCert = $this->createTempFile('smime_cert_');
221
		$tmpMessageFile = $this->createTempFile('smime_msg_');
222
		$tmpOutCert = $this->createTempFile('smime_out_');
223
224
		file_put_contents($tmpMessageFile, $eml);
225
226
		[$fromGAB, $availableCerts] = $this->collectGabCertificate($userProps);
227
228
		if (!$fromGAB && isset($GLOBALS['operations'])) {
229
			$emailAddr = $this->resolveSenderEmail($message, $userProps);
230
			if (!empty($emailAddr)) {
231
				$availableCerts = array_merge($availableCerts, $this->getUserStoreCertificates($emailAddr));
232
			}
233
		}
234
235
		try {
236
			$verification = $this->verifyUsingCertificates($availableCerts, $tmpMessageFile, $tmpOutCert, $tmpUserCert);
237
			if ($verification['status'] === 'retry') {
238
				$verification = $this->verifyUsingMessageCertificate($tmpMessageFile, $tmpOutCert);
239
			}
240
241
			if ($verification['status'] === 'import' && !$fromGAB && !empty($verification['parsedImportCert'])) {
242
				$this->importVerifiedCertificate($verification['importCert'], $verification['parsedImportCert']);
243
			}
244
		}
245
		finally {
246
			$this->cleanupTempFiles([$tmpOutCert, $tmpMessageFile, $tmpUserCert]);
247
		}
248
	}
249
250
	/**
251
	 * Retrieve public certificate from the GAB when available for the sender.
252
	 *
253
	 * @param array $userProps sender related MAPI properties
254
	 *
255
	 * @return array two-element array with GAB flag and certificate list
256
	 */
257
	private function collectGabCertificate(array $userProps) {
258
		$certificates = [];
259
		$fromGAB = false;
260
261
		if (!isset($userProps[PR_SENT_REPRESENTING_ENTRYID])) {
262
			return [$fromGAB, $certificates];
263
		}
264
265
		try {
266
			$user = mapi_ab_openentry($GLOBALS['mapisession']->getAddressbook(), $userProps[PR_SENT_REPRESENTING_ENTRYID]);
267
			$gabCert = $this->getGABCert($user);
268
			if (!empty($gabCert)) {
269
				$fromGAB = true;
270
				$certificates[] = $gabCert;
271
			}
272
		}
273
		catch (MAPIException $exception) {
274
			$exception->setHandled();
275
			$msg = "[smime] Unable to open PR_SENT_REPRESENTING_ENTRYID. Maybe %s was does not exists or deleted from server.";
276
			Log::write(LOGLEVEL_ERROR, sprintf($msg, $userProps[PR_SENT_REPRESENTING_NAME] ?? ''));
277
			error_log("[smime] Unable to open PR_SENT_REPRESENTING_NAME: " . var_export($userProps[PR_SENT_REPRESENTING_NAME] ?? null, true));
278
			$this->message['success'] = SMIME_NOPUB;
279
			$this->message['info'] = SMIME_USER_DETECT_FAILURE;
280
		}
281
282
		return [$fromGAB, $certificates];
283
	}
284
285
	/**
286
	 * Derive sender SMTP address through message or fallback properties.
287
	 *
288
	 * @param mixed $message   MAPI message resource
289
	 * @param array $userProps sender related MAPI properties
290
	 *
291
	 * @return null|string SMTP address when resolved, null otherwise
292
	 */
293
	private function resolveSenderEmail($message, array $userProps) {
294
		$senderAddressArray = $this->getSenderAddress($message);
295
		$senderProps = $senderAddressArray['props'] ?? [];
296
		$addressType = $senderProps['address_type'] ?? '';
297
		$emailAddr = '';
298
299
		if ($addressType === 'SMTP') {
300
			$emailAddr = $senderProps['email_address'] ?? '';
301
		}
302
		else {
303
			$emailAddr = $senderProps['smtp_address'] ?? '';
304
		}
305
306
		if (!empty($emailAddr)) {
307
			return $emailAddr;
308
		}
309
310
		if (!empty($userProps[PR_SENT_REPRESENTING_NAME])) {
311
			return $userProps[PR_SENT_REPRESENTING_NAME];
312
		}
313
314
		$searchKeys = mapi_getprops($message, [PR_SEARCH_KEY, PR_SENT_REPRESENTING_SEARCH_KEY]);
315
		$searchKey = $searchKeys[PR_SEARCH_KEY] ?? $searchKeys[PR_SENT_REPRESENTING_SEARCH_KEY] ?? null;
316
		if ($searchKey) {
317
			$parts = explode(':', (string) $searchKey, 2);
318
			if (count($parts) === 2) {
319
				return trim(strtolower($parts[1]));
320
			}
321
		}
322
323
		return null;
324
	}
325
326
	/**
327
	 * Fetch and decode public certificates stored in the user store for an address.
328
	 *
329
	 * @param string $emailAddr SMTP address of the sender
330
	 *
331
	 * @return array list of decoded certificates
332
	 */
333
	private function getUserStoreCertificates($emailAddr) {
334
		$userCerts = (array) $this->getPublicKey($emailAddr, true);
335
		if ($userCerts === []) {
336
			return [];
337
		}
338
339
		$decoded = [];
340
		foreach ($userCerts as $cert) {
341
			$decodedCert = base64_decode((string) $cert);
342
			if (!empty($decodedCert)) {
343
				$decoded[] = $decodedCert;
344
			}
345
		}
346
347
		return $decoded;
348
	}
349
350
	/**
351
	 * Attempt verification using certificates already known to the system.
352
	 *
353
	 * @param array  $certs       candidate certificates in PEM format
354
	 * @param string $messageFile temporary file containing the message payload
355
	 * @param string $outCertFile temporary file to receive the extracted certificate
356
	 * @param string $tmpUserCert temporary file for passing certificates to OpenSSL
357
	 *
358
	 * @return array verification result metadata
359
	 */
360
	private function verifyUsingCertificates(array $certs, $messageFile, $outCertFile, $tmpUserCert) {
361
		if (empty($certs)) {
362
			return ['status' => 'retry', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => null];
363
		}
364
365
		$caBundle = explode(';', PLUGIN_SMIME_CACERTS);
366
		$caCerts = null;
367
368
		foreach ($certs as $cert) {
369
			if (empty($cert)) {
370
				continue;
371
			}
372
373
			file_put_contents($tmpUserCert, $cert);
374
			$this->clear_openssl_error();
375
			$signedOk = openssl_pkcs7_verify($messageFile, PKCS7_NOINTERN, $outCertFile, $caBundle, $tmpUserCert);
376
			$opensslError = $this->extract_openssl_error();
377
			$this->validateSignedMessage($signedOk, $opensslError);
378
379
			if (!$signedOk || $opensslError === OPENSSL_CA_VERIFY_FAIL) {
380
				continue;
381
			}
382
383
			$importCert = file_get_contents($outCertFile);
384
			if ($importCert === false || $importCert === '') {
385
				continue;
386
			}
387
388
			$parsedImport = openssl_x509_parse($importCert);
389
			$parsedUser = openssl_x509_parse($cert);
390
			$caCerts = $caCerts ?? $this->extractCAs($messageFile);
391
392
			if (
393
				$parsedImport !== false &&
394
				$parsedUser !== false &&
395
				($parsedImport['validTo'] ?? '') > ($parsedUser['validTo'] ?? '') &&
396
				($parsedImport['validFrom'] ?? '') > ($parsedUser['validFrom'] ?? '') &&
397
				getCertEmail($parsedImport) === getCertEmail($parsedUser) &&
398
				verifyOCSP($importCert, $caCerts, $this->message)
399
			) {
400
				return [
401
					'status' => 'import',
402
					'importCert' => $importCert,
403
					'parsedImportCert' => $parsedImport,
404
					'caCerts' => $caCerts,
405
				];
406
			}
407
408
			verifyOCSP($cert, $caCerts, $this->message);
409
410
			return ['status' => 'skip', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => $caCerts];
411
		}
412
413
		return ['status' => 'retry', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => null];
414
	}
415
416
	/**
417
	 * Fallback verification that relies on the certificate bundled with the message.
418
	 *
419
	 * @param string $messageFile temporary file containing the message payload
420
	 * @param string $outCertFile temporary file for certificate extraction
421
	 *
422
	 * @return array verification result metadata
423
	 */
424
	private function verifyUsingMessageCertificate($messageFile, $outCertFile) {
425
		$caBundle = explode(';', PLUGIN_SMIME_CACERTS);
426
		$this->clear_openssl_error();
427
		$signedOk = openssl_pkcs7_verify($messageFile, PKCS7_NOSIGS, $outCertFile, $caBundle);
428
		$opensslError = $this->extract_openssl_error();
429
		$this->validateSignedMessage($signedOk, $opensslError);
430
431
		if (!$signedOk || $opensslError === OPENSSL_CA_VERIFY_FAIL) {
432
			$this->handleMissingPublicKey();
433
434
			return ['status' => 'skip', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => null];
435
		}
436
437
		$importCert = file_get_contents($outCertFile);
438
		if ($importCert === false || $importCert === '') {
439
			return ['status' => 'skip', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => null];
440
		}
441
442
		$parsedImport = openssl_x509_parse($importCert);
443
		$caCerts = $this->extractCAs($messageFile);
444
445
		if ($parsedImport === false || !verifyOCSP($importCert, $caCerts, $this->message)) {
446
			return ['status' => 'skip', 'importCert' => null, 'parsedImportCert' => null, 'caCerts' => $caCerts];
447
		}
448
449
		return ['status' => 'import', 'importCert' => $importCert, 'parsedImportCert' => $parsedImport, 'caCerts' => $caCerts];
450
	}
451
452
	/**
453
	 * Import a verified certificate into the user store with force-overwrite semantics.
454
	 *
455
	 * @param string $rawCertificate    certificate body in PEM format
456
	 * @param array  $parsedCertificate parsed certificate meta data from OpenSSL
457
	 */
458
	private function importVerifiedCertificate($rawCertificate, array $parsedCertificate) {
459
		$certEmail = getCertEmail($parsedCertificate);
460
		if (!empty($certEmail)) {
461
			$this->importCertificate($rawCertificate, $parsedCertificate, 'public', true);
462
		}
463
	}
464
465
	/**
466
	 * Record diagnostics when a message cannot be verified due to missing keys.
467
	 */
468
	private function handleMissingPublicKey() {
469
		Log::write(LOGLEVEL_INFO, sprintf("[smime] Unable to verify message without public key, openssl error: '%s'", $this->openssl_error));
470
		$this->message['success'] = SMIME_STATUS_FAIL;
471
		$this->message['info'] = SMIME_CA;
472
	}
473
474
	/**
475
	 * Remove temporary files with defensive existence checks.
476
	 *
477
	 * @param array $paths paths scheduled for cleanup
478
	 */
479
	private function cleanupTempFiles(array $paths) {
480
		foreach ($paths as $path) {
481
			if (is_string($path) && $path !== '' && file_exists($path) && !unlink($path)) {
482
				Log::write(LOGLEVEL_WARN, sprintf('[smime] Failed to remove temporary file %s', $path));
483
			}
484
		}
485
	}
486
487
	/**
488
	 * Create a unique temp file using the supplied prefix.
489
	 *
490
	 * @param string $prefix file name prefix
491
	 *
492
	 * @return string path to the created temp file
493
	 */
494
	private function createTempFile($prefix) {
495
		return tempnam(sys_get_temp_dir(), $prefix);
496
	}
497
498
	public function join_xph(&$prop, $msg) {
499
		$a = mapi_getprops($msg, [PR_TRANSPORT_MESSAGE_HEADERS]);
500
		$a = $a === false ? "" : ($a[PR_TRANSPORT_MESSAGE_HEADERS] ?? "");
501
		$prop[PR_TRANSPORT_MESSAGE_HEADERS] =
502
			"# Outer headers:\n" . ($prop[PR_TRANSPORT_MESSAGE_HEADERS] ?? "") .
503
			"# Inner headers:\n" . $a;
504
	}
505
506
	/**
507
	 * Function which decrypts an encrypted message.
508
	 * The key should be unlocked and stored in the EncryptionStore for a successful decrypt
509
	 * If the key isn't in the session, we give the user a message to unlock his certificate.
510
	 *
511
	 * @param mixed $data array of data from hook
512
	 */
513
	public function onEncrypted($data) {
514
		// Cert unlocked, decode message
515
		$this->message['success'] = SMIME_STATUS_INFO;
516
		$this->message['info'] = SMIME_DECRYPT_FAILURE;
517
518
		$this->message['type'] = 'encrypted';
519
		$encryptionStore = EncryptionStore::getInstance();
520
		$pass = $encryptionStore->get('smime');
521
522
		$tmpFile = tempnam(sys_get_temp_dir(), true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $prefix of tempnam(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

522
		$tmpFile = tempnam(sys_get_temp_dir(), /** @scrutinizer ignore-type */ true);
Loading history...
523
		// Write mime header. Because it's not provided in the attachment, otherwise openssl won't parse it
524
		$fp = fopen($tmpFile, 'w');
525
		fwrite($fp, "Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\n");
526
		fwrite($fp, "Content-Transfer-Encoding: base64\nContent-Disposition: attachment; filename=\"smime.p7m\"\n");
527
		fwrite($fp, "Content-Description: S/MIME Encrypted Message\n\n");
528
		fwrite($fp, chunk_split(base64_encode((string) $data['data']), 72) . "\n");
529
		fclose($fp);
530
		if (isset($pass) && !empty($pass)) {
531
			$certs = readPrivateCert($this->getStore(), $pass, false);
0 ignored issues
show
Bug introduced by
$this->getStore() of type object is incompatible with the type resource expected by parameter $store of readPrivateCert(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

531
			$certs = readPrivateCert(/** @scrutinizer ignore-type */ $this->getStore(), $pass, false);
Loading history...
532
			// create random file for saving the encrypted and body message
533
			$tmpDecrypted = tempnam(sys_get_temp_dir(), true);
534
535
			$decryptStatus = false;
536
			// If multiple private certs were decrypted with supplied password
537
			if (!$certs['cert'] && count($certs) > 0) {
538
				foreach ($certs as $cert) {
539
					$this->clear_openssl_error();
540
					$decryptStatus = openssl_pkcs7_decrypt($tmpFile, $tmpDecrypted, $cert['cert'], [$cert['pkey'], $pass]);
541
					if ($decryptStatus !== false) {
542
						break;
543
					}
544
				}
545
			}
546
			else {
547
				$this->clear_openssl_error();
548
				$decryptStatus = openssl_pkcs7_decrypt($tmpFile, $tmpDecrypted, $certs['cert'], [$certs['pkey'], $pass]);
549
			}
550
551
			$ossl_error = $this->extract_openssl_error();
552
			$content = file_get_contents($tmpDecrypted);
553
			// Handle OL empty body Outlook Signed & Encrypted mails.
554
			// The S/MIME plugin has to extract the body from the signed message.
555
			if (str_contains($content, 'signed-data')) {
556
				$this->message['type'] = 'encryptsigned';
557
				$olcert = tempnam(sys_get_temp_dir(), true);
558
				$olmsg = tempnam(sys_get_temp_dir(), true);
559
				openssl_pkcs7_verify($tmpDecrypted, PKCS7_NOVERIFY, $olcert);
560
				openssl_pkcs7_verify($tmpDecrypted, PKCS7_NOVERIFY, $olcert, [], $olcert, $olmsg);
561
				$content = file_get_contents($olmsg);
562
				unlink($olmsg);
563
				unlink($olcert);
564
			}
565
566
			$copyProps = mapi_getprops($data['message'], [PR_MESSAGE_DELIVERY_TIME, PR_SENDER_ENTRYID, PR_SENT_REPRESENTING_ENTRYID, PR_TRANSPORT_MESSAGE_HEADERS]);
567
			mapi_inetmapi_imtomapi($GLOBALS['mapisession']->getSession(), $data['store'], $GLOBALS['mapisession']->getAddressbook(), $data['message'], $content, ['parse_smime_signed' => true]);
568
			$this->join_xph($copyProps, $data['message']);
569
			// Manually set time back to the received time, since mapi_inetmapi_imtomapi overwrites this
570
			mapi_setprops($data['message'], $copyProps);
571
572
			// remove temporary files
573
			unlink($tmpFile);
574
			unlink($tmpDecrypted);
575
576
			// mapi_inetmapi_imtomapi removes the PR_MESSAGE_CLASS = 'IPM.Note.SMIME.MultipartSigned'
577
			// So we need to check if the message was also signed by looking at the MIME_TAG in the eml
578
			if (str_contains($content, 'multipart/signed') || str_contains($content, 'signed-data')) {
579
				$this->message['type'] = 'encryptsigned';
580
				$this->verifyMessage($data['message'], $content);
581
			}
582
			elseif ($decryptStatus) {
583
				$this->message['info'] = SMIME_DECRYPT_SUCCESS;
584
				$this->message['success'] = SMIME_STATUS_SUCCESS;
585
			}
586
			elseif ($ossl_error === OPENSSL_RECIPIENT_CERTIFICATE_MISMATCH) {
587
				error_log("[smime] Error when decrypting email, openssl error: " . print_r($this->openssl_error, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($this->openssl_error, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

587
				error_log("[smime] Error when decrypting email, openssl error: " . /** @scrutinizer ignore-type */ print_r($this->openssl_error, true));
Loading history...
588
				Log::Write(LOGLEVEL_ERROR, sprintf("[smime] Error when decrypting email, openssl error: '%s'", $this->openssl_error));
589
				$this->message['info'] = SMIME_DECRYPT_CERT_MISMATCH;
590
				$this->message['success'] = SMIME_STATUS_FAIL;
591
			}
592
		}
593
		else {
594
			// it might also be a signed message only. Verify it.
595
			$msg = tempnam(sys_get_temp_dir(), true);
596
			$ret = openssl_pkcs7_verify($tmpFile, PKCS7_NOVERIFY, null, [], null, $msg);
597
			$content = file_get_contents($msg);
598
			unlink($tmpFile);
599
			unlink($msg);
600
			if ($ret === true && !empty($content)) {
601
				$copyProps = mapi_getprops($data['message'], [PR_MESSAGE_DELIVERY_TIME, PR_SENDER_ENTRYID, PR_SENT_REPRESENTING_ENTRYID, PR_TRANSPORT_MESSAGE_HEADERS]);
602
				mapi_inetmapi_imtomapi(
603
					$GLOBALS['mapisession']->getSession(),
604
					$data['store'],
605
					$GLOBALS['mapisession']->getAddressbook(),
606
					$data['message'],
607
					$content,
608
					['parse_smime_signed' => true]
609
				);
610
				$this->join_xph($copyProps, $data['message']);
611
				// Manually set time back to the received time, since mapi_inetmapi_imtomapi overwrites this
612
				mapi_setprops($data['message'], $copyProps);
613
				$this->message['type'] = 'encryptsigned';
614
				$this->message['info'] = SMIME_DECRYPT_SUCCESS;
615
				$this->message['success'] = SMIME_STATUS_SUCCESS;
616
			}
617
			else {
618
				$this->message['info'] = SMIME_UNLOCK_CERT;
619
			}
620
		}
621
622
		if (!encryptionStoreExpirationSupport()) {
623
			withPHPSession(function () use ($encryptionStore) {
624
				$encryptionStore->add('smime', '');
625
			});
626
		}
627
	}
628
629
	/**
630
	 * Function which calls verifyMessage to verify if the message isn't malformed during transport.
631
	 *
632
	 * @param mixed $data array of data from hook
633
	 */
634
	public function onSignedMessage($data) {
635
		$this->message['type'] = 'signed';
636
		$this->verifyMessage($data['message'], $data['data']);
637
	}
638
639
	/**
640
	 * General function which parses the openssl_pkcs7_verify return value and the errors generated by
641
	 * openssl_error_string().
642
	 *
643
	 * @param mixed $openssl_return
644
	 * @param mixed $openssl_errors
645
	 */
646
	public function validateSignedMessage($openssl_return, $openssl_errors) {
647
		if ($openssl_return === -1) {
648
			$this->message['info'] = SMIME_ERROR;
649
			$this->message['success'] = SMIME_STATUS_FAIL;
650
651
			return;
652
			// Verification was successful
653
		}
654
		if ($openssl_return) {
655
			$this->message['info'] = SMIME_SUCCESS;
656
			$this->message['success'] = SMIME_STATUS_SUCCESS;
657
658
			return;
659
			// Verification was not successful, display extra information.
660
		}
661
		$this->message['success'] = SMIME_STATUS_FAIL;
662
		if ($openssl_errors === OPENSSL_CA_VERIFY_FAIL) {
663
			$this->message['info'] = SMIME_CA;
664
		}
665
		else { // Catch general errors
666
			$this->message['info'] = SMIME_ERROR;
667
		}
668
	}
669
670
	/**
671
	 * Set smime key in $data array, which is send back to client
672
	 * Since we can't create this array key in the hooks:
673
	 * 'server.util.parse_smime.signed'
674
	 * 'server.util.parse_smime.encrypted'.
675
	 *
676
	 * TODO: investigate if we can move away from this hook
677
	 *
678
	 * @param mixed $data
679
	 */
680
	public function onAfterOpen($data) {
681
		if (isset($this->message) && !empty($this->message)) {
682
			$data['data']['item']['props']['smime'] = $this->message;
683
		}
684
	}
685
686
	/**
687
	 * Handles the uploaded certificate in the settingsmenu in grommunio Web
688
	 * - Opens the certificate with provided passphrase
689
	 * - Checks if it can be used for signing/decrypting
690
	 * - Verifies that the email address is equal to the
691
	 * - Verifies that the certificate isn't expired and inform user.
692
	 *
693
	 * @param mixed $data
694
	 */
695
	public function onUploadCertificate($data) {
696
		if ($data['sourcetype'] !== 'certificate') {
697
			return;
698
		}
699
		$passphrase = $_POST['passphrase'];
700
		$saveCert = false;
701
		$tmpname = $data['tmpname'];
702
		$message = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $message is dead and can be removed.
Loading history...
703
		$imported = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $imported is dead and can be removed.
Loading history...
704
705
		$certificate = file_get_contents($tmpname);
706
		$emailAddress = $GLOBALS['mapisession']->getSMTPAddress();
707
		[$message, $publickey, $publickeyData, $imported] = validateUploadedPKCS($certificate, $passphrase, $emailAddress);
708
709
		// All checks completed successful
710
		// Store private cert in users associated store (check for duplicates)
711
		if ($imported) {
712
			$certMessage = getMAPICert($this->getStore());
0 ignored issues
show
Bug introduced by
$this->getStore() of type object is incompatible with the type resource expected by parameter $store of getMAPICert(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

712
			$certMessage = getMAPICert(/** @scrutinizer ignore-type */ $this->getStore());
Loading history...
713
			// TODO: update to serialNumber check
714
			if ($certMessage && $certMessage[0][PR_MESSAGE_DELIVERY_TIME] == $publickeyData['validTo_time_t']) {
715
				$message = _('Certificate is already stored on the server');
716
			}
717
			else {
718
				$saveCert = true;
719
				$root = mapi_msgstore_openentry($this->getStore());
0 ignored issues
show
Unused Code introduced by
The assignment to $root is dead and can be removed.
Loading history...
720
				// Remove old certificate
721
				/*
722
				if($certMessage) {
723
					// Delete private key
724
					mapi_folder_deletemessages($root, array($certMessage[PR_ENTRYID]));
725
726
					// Delete public key
727
					$pubCert = getMAPICert($this->getStore, 'WebApp.Security.Public', getCertEmail($certMessage));
728
					if($pubCert) {
729
						mapi_folder_deletemessages($root, array($pubCert[PR_ENTRYID]));
730
					}
731
					$message = _('New certificate uploaded');
732
				} else {
733
					$message = _('Certificate uploaded');
734
				}*/
735
736
				$this->importCertificate($certificate, $publickeyData, 'private');
737
738
				// Check if the user has a public key in the GAB.
739
				$store_props = mapi_getprops($this->getStore(), [PR_USER_ENTRYID]);
740
				$user = mapi_ab_openentry($GLOBALS['mapisession']->getAddressbook(), $store_props[PR_USER_ENTRYID]);
0 ignored issues
show
Unused Code introduced by
The assignment to $user is dead and can be removed.
Loading history...
741
742
				$this->importCertificate($publickey, $publickeyData, 'public', true);
743
			}
744
		}
745
746
		$returnfiles = [];
747
		$returnfiles[] = [
748
			'props' => [
749
				'attach_num' => -1,
750
				'size' => $data['size'],
751
				'name' => $data['name'],
752
				'cert' => $saveCert,
753
				'cert_warning' => $message,
754
			],
755
		];
756
		$data['returnfiles'] = $returnfiles;
757
	}
758
759
	/**
760
	 * This function handles the 'beforesend' hook which is triggered before sending the email.
761
	 * If the PR_MESSAGE_CLASS is set to a signed email (IPM.Note.SMIME.Multipartsigned), this function
762
	 * will convert the mapi message to RFC822, sign the eml and attach the signed email to the mapi message.
763
	 *
764
	 * @param mixed $data from php hook
765
	 */
766
	public function onBeforeSend(&$data) {
767
		$store = $data['store'];
0 ignored issues
show
Unused Code introduced by
The assignment to $store is dead and can be removed.
Loading history...
768
		$message = $data['message'];
769
770
		// Retrieve message class
771
		$props = mapi_getprops($message, [PR_MESSAGE_CLASS]);
772
		$messageClass = $props[PR_MESSAGE_CLASS];
773
774
		if (!isset($messageClass)) {
775
			return;
776
		}
777
		if (stripos((string) $messageClass, 'IPM.Note.deferSMIME') === false &&
778
			stripos((string) $messageClass, 'IPM.Note.SMIME') === false) {
779
			return;
780
		}
781
782
		// FIXME: for now return when we are going to sign but we don't have the passphrase set
783
		// This should never happen sign
784
		$encryptionStore = EncryptionStore::getInstance();
785
		if (($messageClass === 'IPM.Note.deferSMIME.SignedEncrypt' ||
786
			$messageClass === 'IPM.Note.deferSMIME.MultipartSigned' ||
787
			$messageClass === 'IPM.Note.SMIME.SignedEncrypt' ||
788
			$messageClass === 'IPM.Note.SMIME.MultipartSigned') &&
789
			!$encryptionStore->get('smime')) {
790
			return;
791
		}
792
		// NOTE: setting message class to IPM.Note, so that mapi_inetmapi_imtoinet converts the message to plain email
793
		// and doesn't fail when handling the attachments.
794
		mapi_setprops($message, [PR_MESSAGE_CLASS => 'IPM.Note']);
795
		mapi_savechanges($message);
796
797
		// Read the message as RFC822-formatted e-mail stream.
798
		$emlMessageStream = mapi_inetmapi_imtoinet($GLOBALS['mapisession']->getSession(), $GLOBALS['mapisession']->getAddressbook(), $message, []);
799
800
		// Remove all attachments, since they are stored in the attached signed message
801
		$atable = mapi_message_getattachmenttable($message);
802
		$rows = mapi_table_queryallrows($atable, [PR_ATTACH_MIME_TAG, PR_ATTACH_NUM]);
803
		foreach ($rows as $row) {
804
			$attnum = $row[PR_ATTACH_NUM];
805
			mapi_message_deleteattach($message, $attnum);
806
		}
807
808
		// create temporary files
809
		$tmpSendEmail = tempnam(sys_get_temp_dir(), true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $prefix of tempnam(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

809
		$tmpSendEmail = tempnam(sys_get_temp_dir(), /** @scrutinizer ignore-type */ true);
Loading history...
810
		$tmpSendSmimeEmail = tempnam(sys_get_temp_dir(), true);
811
812
		// Save message stream to a file
813
		$stat = mapi_stream_stat($emlMessageStream);
814
815
		$fhandle = fopen($tmpSendEmail, 'w');
816
		$buffer = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $buffer is dead and can be removed.
Loading history...
817
		for ($i = 0; $i < $stat["cb"]; $i += BLOCK_SIZE) {
818
			// Write stream
819
			$buffer = mapi_stream_read($emlMessageStream, BLOCK_SIZE);
820
			fwrite($fhandle, $buffer, strlen($buffer));
821
		}
822
		fclose($fhandle);
823
824
		// Create attachment for S/MIME message
825
		$signedAttach = mapi_message_createattach($message);
826
		$smimeProps = [
827
			PR_ATTACH_LONG_FILENAME => 'smime.p7m',
828
			PR_DISPLAY_NAME => 'smime.p7m',
829
			PR_ATTACH_METHOD => ATTACH_BY_VALUE,
830
			PR_ATTACH_MIME_TAG => 'multipart/signed',
831
			PR_ATTACHMENT_HIDDEN => true,
832
		];
833
834
		// Sign then Encrypt email
835
		switch ($messageClass) {
836
			case 'IPM.Note.deferSMIME.SignedEncrypt':
837
			case 'IPM.Note.SMIME.SignedEncrypt':
838
				$tmpFile = tempnam(sys_get_temp_dir(), true);
839
				$this->sign($tmpSendEmail, $tmpFile, $message, $signedAttach, $smimeProps);
840
				$this->encrypt($tmpFile, $tmpSendSmimeEmail, $message, $signedAttach, $smimeProps);
841
				unlink($tmpFile);
842
				break;
843
844
			case 'IPM.Note.deferSMIME.MultipartSigned':
845
			case 'IPM.Note.SMIME.MultipartSigned':
846
				$this->sign($tmpSendEmail, $tmpSendSmimeEmail, $message, $signedAttach, $smimeProps);
847
				break;
848
849
			case 'IPM.Note.deferSMIME':
850
			case 'IPM.Note.SMIME':
851
				$this->encrypt($tmpSendEmail, $tmpSendSmimeEmail, $message, $signedAttach, $smimeProps);
852
				break;
853
		}
854
855
		// Save the signed message as attachment of the send email
856
		$stream = mapi_openproperty($signedAttach, PR_ATTACH_DATA_BIN, IID_IStream, 0, MAPI_CREATE | MAPI_MODIFY);
857
		$handle = fopen($tmpSendSmimeEmail, 'r');
858
		while (!feof($handle)) {
859
			$contents = fread($handle, BLOCK_SIZE);
860
			mapi_stream_write($stream, $contents);
861
		}
862
		fclose($handle);
863
864
		mapi_stream_commit($stream);
865
866
		// remove tmp files
867
		unlink($tmpSendSmimeEmail);
868
		unlink($tmpSendEmail);
869
870
		mapi_savechanges($signedAttach);
871
		mapi_savechanges($message);
872
	}
873
874
	/**
875
	 * Function to sign an email.
876
	 *
877
	 * @param string $infile       File eml to be encrypted
878
	 * @param string $outfile      File
879
	 * @param object $message      Mapi Message Object
880
	 * @param object $signedAttach
881
	 * @param array  $smimeProps
882
	 */
883
	public function sign(&$infile, &$outfile, &$message, &$signedAttach, $smimeProps) {
884
		// Set mesageclass back to IPM.Note.SMIME.MultipartSigned
885
		mapi_setprops($message, [PR_MESSAGE_CLASS => 'IPM.Note.SMIME.MultipartSigned']);
886
		mapi_setprops($signedAttach, $smimeProps);
887
888
		// Obtain private certificate
889
		$encryptionStore = EncryptionStore::getInstance();
890
		// Only the newest one is returned
891
		$certs = readPrivateCert($this->getStore(), $encryptionStore->get('smime'));
0 ignored issues
show
Bug introduced by
$this->getStore() of type object is incompatible with the type resource expected by parameter $store of readPrivateCert(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

891
		$certs = readPrivateCert(/** @scrutinizer ignore-type */ $this->getStore(), $encryptionStore->get('smime'));
Loading history...
892
		// Retrieve intermediate CA's for verification, if available
893
		$flags = PKCS7_DETACHED | PKCS7_TEXT;
894
		if (isset($certs['extracerts'])) {
895
			$tmpFile = tempnam(sys_get_temp_dir(), true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $prefix of tempnam(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

895
			$tmpFile = tempnam(sys_get_temp_dir(), /** @scrutinizer ignore-type */ true);
Loading history...
896
			file_put_contents($tmpFile, implode('', $certs['extracerts']));
897
			$ok = openssl_pkcs7_sign($infile, $outfile, $certs['cert'], [$certs['pkey'], ''], [], $flags, $tmpFile);
898
			if (!$ok) {
899
				Log::Write(LOGLEVEL_ERROR, sprintf("[smime] Unable to sign message with intermediate certificates, openssl error: '%s'", @openssl_error_string()));
0 ignored issues
show
Bug introduced by
It seems like @openssl_error_string() can also be of type false; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

899
				Log::Write(LOGLEVEL_ERROR, sprintf("[smime] Unable to sign message with intermediate certificates, openssl error: '%s'", /** @scrutinizer ignore-type */ @openssl_error_string()));
Loading history...
900
			}
901
			unlink($tmpFile);
902
		}
903
		else {
904
			$ok = openssl_pkcs7_sign($infile, $outfile, $certs['cert'], [$certs['pkey'], ''], [], $flags);
905
			if (!$ok) {
906
				Log::Write(LOGLEVEL_ERROR, sprintf("[smime] Unable to sign message, openssl error: '%s'", @openssl_error_string()));
907
			}
908
		}
909
	}
910
911
	/**
912
	 * Function to encrypt an email.
913
	 *
914
	 * @param string $infile       File eml to be encrypted
915
	 * @param string $outfile      File
916
	 * @param object $message      Mapi Message Object
917
	 * @param object $signedAttach
918
	 * @param array  $smimeProps
919
	 */
920
	public function encrypt(&$infile, &$outfile, &$message, &$signedAttach, $smimeProps) {
921
		mapi_setprops($message, [PR_MESSAGE_CLASS => 'IPM.Note.SMIME']);
922
		$smimeProps[PR_ATTACH_MIME_TAG] = "application/pkcs7-mime";
923
		mapi_setprops($signedAttach, $smimeProps);
924
925
		$publicCerts = $this->getPublicKeyForMessage($message);
926
		// Always append our own certificate, so that the mail can be decrypted in 'Sent items'
927
		// Prefer GAB public certificate above MAPI Store certificate.
928
		$email = $GLOBALS['mapisession']->getSMTPAddress();
929
		$user = $this->getGABUser($email);
930
		$cert = $this->getGABCert($user);
931
		if (empty($cert)) {
932
			$cert = base64_decode($this->getPublicKey($email));
933
		}
934
935
		if (!empty($cert)) {
936
			array_push($publicCerts, $cert);
937
		}
938
939
		$ok = openssl_pkcs7_encrypt($infile, $outfile, $publicCerts, [], 0, $this->cipher);
940
		if (!$ok) {
941
			error_log("[smime] unable to encrypt message, openssl error: " . print_r(@openssl_error_string(), true));
0 ignored issues
show
Bug introduced by
Are you sure print_r(@openssl_error_string(), true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

941
			error_log("[smime] unable to encrypt message, openssl error: " . /** @scrutinizer ignore-type */ print_r(@openssl_error_string(), true));
Loading history...
942
			Log::Write(LOGLEVEL_ERROR, sprintf("[smime] unable to encrypt message, openssl error: '%s'", @openssl_error_string()));
0 ignored issues
show
Bug introduced by
It seems like @openssl_error_string() can also be of type false; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

942
			Log::Write(LOGLEVEL_ERROR, sprintf("[smime] unable to encrypt message, openssl error: '%s'", /** @scrutinizer ignore-type */ @openssl_error_string()));
Loading history...
943
		}
944
		$tmpEml = file_get_contents($outfile);
945
946
		// Grab the base64 data, since MAPI requires it saved as decoded base64 string.
947
		// FIXME: we can do better here
948
		$matches = explode("\n\n", $tmpEml);
949
		$base64 = str_replace("\n", "", $matches[1]);
950
		file_put_contents($outfile, base64_decode($base64));
951
952
		// Empty the body
953
		mapi_setprops($message, [PR_BODY => ""]);
954
	}
955
956
	/**
957
	 * Function which fetches the public certificates for all recipients (TO/CC/BCC) of a message
958
	 * Always get the certificate of an address which expires last.
959
	 *
960
	 * @param object $message Mapi Message Object
961
	 *
962
	 * @return array of public certificates
963
	 */
964
	public function getPublicKeyForMessage($message) {
965
		$recipientTable = mapi_message_getrecipienttable($message);
966
		$recips = mapi_table_queryallrows($recipientTable, [PR_SMTP_ADDRESS, PR_RECIPIENT_TYPE, PR_ADDRTYPE], [RES_OR, [
967
			[RES_PROPERTY,
968
				[
969
					RELOP => RELOP_EQ,
970
					ULPROPTAG => PR_RECIPIENT_TYPE,
971
					VALUE => MAPI_BCC,
972
				],
973
			],
974
			[RES_PROPERTY,
975
				[
976
					RELOP => RELOP_EQ,
977
					ULPROPTAG => PR_RECIPIENT_TYPE,
978
					VALUE => MAPI_CC,
979
				],
980
			],
981
			[RES_PROPERTY,
982
				[
983
					RELOP => RELOP_EQ,
984
					ULPROPTAG => PR_RECIPIENT_TYPE,
985
					VALUE => MAPI_TO,
986
				],
987
			],
988
		]]);
989
990
		$publicCerts = [];
991
		$storeCert = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $storeCert is dead and can be removed.
Loading history...
992
		$gabCert = '';
993
994
		foreach ($recips as $recip) {
995
			$emailAddr = $recip[PR_SMTP_ADDRESS];
996
			$addrType = $recip[PR_ADDRTYPE];
997
998
			if ($addrType === "ZARAFA" || $addrType === "EX") {
999
				$user = $this->getGABUser($emailAddr);
1000
				$gabCert = $this->getGABCert($user);
1001
			}
1002
1003
			$storeCert = $this->getPublicKey($emailAddr);
1004
1005
			if (!empty($gabCert)) {
1006
				array_push($publicCerts, $gabCert);
1007
			}
1008
			elseif (!empty($storeCert)) {
1009
				array_push($publicCerts, base64_decode($storeCert));
1010
			}
1011
		}
1012
1013
		return $publicCerts;
1014
	}
1015
1016
	/**
1017
	 * Retrieves the public certificates stored in the MAPI UserStore and belonging to the
1018
	 * emailAdddress, returns "" if there is no certificate for that user.
1019
	 *
1020
	 * @param string emailAddress
0 ignored issues
show
Bug introduced by
The type emailAddress was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1021
	 * @param mixed $emailAddress
1022
	 * @param mixed $multiple
1023
	 *
1024
	 * @return string $certificate
1025
	 */
1026
	public function getPublicKey($emailAddress, $multiple = false) {
1027
		$certificates = [];
1028
1029
		$certs = getMAPICert($this->getStore(), 'WebApp.Security.Public', $emailAddress);
0 ignored issues
show
Bug introduced by
$this->getStore() of type object is incompatible with the type resource expected by parameter $store of getMAPICert(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1029
		$certs = getMAPICert(/** @scrutinizer ignore-type */ $this->getStore(), 'WebApp.Security.Public', $emailAddress);
Loading history...
1030
1031
		if ($certs && count($certs) > 0) {
0 ignored issues
show
Bug introduced by
$certs of type resource|true is incompatible with the type Countable|array expected by parameter $value of count(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1031
		if ($certs && count(/** @scrutinizer ignore-type */ $certs) > 0) {
Loading history...
1032
			foreach ($certs as $cert) {
0 ignored issues
show
Bug introduced by
The expression $certs of type resource|true is not traversable.
Loading history...
1033
				$pubkey = mapi_msgstore_openentry($this->getStore(), $cert[PR_ENTRYID]);
1034
				$certificate = "";
1035
				if ($pubkey == false) {
1036
					continue;
1037
				}
1038
				// retrieve pkcs#11 certificate from body
1039
				$stream = mapi_openproperty($pubkey, PR_BODY, IID_IStream, 0, 0);
1040
				$stat = mapi_stream_stat($stream);
1041
				mapi_stream_seek($stream, 0, STREAM_SEEK_SET);
1042
				for ($i = 0; $i < $stat['cb']; $i += 1024) {
1043
					$certificate .= mapi_stream_read($stream, 1024);
1044
				}
1045
				array_push($certificates, $certificate);
1046
			}
1047
		}
1048
1049
		return $multiple ? $certificates : ($certificates[0] ?? '');
1050
	}
1051
1052
	/**
1053
	 * Function which is used to check if there is a public certificate for the provided emailAddress.
1054
	 *
1055
	 * @param string emailAddress emailAddres of recipient
1056
	 * @param bool gabUser is the user of PR_ADDRTYPE == ZARAFA
0 ignored issues
show
Bug introduced by
The type gabUser was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1057
	 * @param mixed $emailAddress
1058
	 * @param mixed $gabUser
1059
	 *
1060
	 * @return bool true if public certificate exists
1061
	 */
1062
	public function pubcertExists($emailAddress, $gabUser = false) {
1063
		if ($gabUser) {
1064
			$user = $this->getGABUser($emailAddress);
1065
			$gabCert = $this->getGABCert($user);
1066
			if ($user && !empty($gabCert)) {
1067
				return true;
1068
			}
1069
		}
1070
1071
		$root = mapi_msgstore_openentry($this->getStore());
1072
		$table = mapi_folder_getcontentstable($root, MAPI_ASSOCIATED);
1073
1074
		// Restriction for public certificates which are from the recipient of the email, are active and have the correct message_class
1075
		$restrict = [RES_AND, [
1076
			[RES_PROPERTY,
1077
				[
1078
					RELOP => RELOP_EQ,
1079
					ULPROPTAG => PR_MESSAGE_CLASS,
1080
					VALUE => [PR_MESSAGE_CLASS => "WebApp.Security.Public"],
1081
				],
1082
			],
1083
			[RES_PROPERTY,
1084
				[
1085
					RELOP => RELOP_EQ,
1086
					ULPROPTAG => PR_SUBJECT,
1087
					VALUE => [PR_SUBJECT => $emailAddress],
1088
				],
1089
			],
1090
		]];
1091
		mapi_table_restrict($table, $restrict, TBL_BATCH);
1092
		mapi_table_sort($table, [PR_MESSAGE_DELIVERY_TIME => TABLE_SORT_DESCEND], TBL_BATCH);
1093
1094
		$rows = mapi_table_queryallrows($table, [PR_SUBJECT, PR_ENTRYID, PR_MESSAGE_DELIVERY_TIME, PR_CLIENT_SUBMIT_TIME], $restrict);
1095
1096
		return !empty($rows);
1097
	}
1098
1099
	public function clear_openssl_error() {
1100
		while (@openssl_error_string() !== false)
1101
		/* nothing */;
1102
	}
1103
1104
	/**
1105
	 * Helper functions which extracts the errors from openssl_error_string()
1106
	 * Example error from openssl_error_string(): error:21075075:PKCS7 routines:PKCS7_verify:certificate verify error
1107
	 * Note that openssl_error_string() returns an error when verifying is successful, this is a bug in PHP https://bugs.php.net/bug.php?id=50713.
1108
	 *
1109
	 * @return string
1110
	 */
1111
	public function extract_openssl_error() {
1112
		$this->openssl_error = "";
1113
		while (($s = @openssl_error_string()) !== false) {
1114
			if (strlen($this->openssl_error) == 0) {
1115
				$this->openssl_error = $s;
1116
			}
1117
			else {
1118
				$this->openssl_error .= "\n" . $s;
1119
			}
1120
		}
1121
		$openssl_error_code = 0;
1122
		if ($this->openssl_error) {
1123
			$openssl_error_list = explode(":", $this->openssl_error);
1124
			$openssl_error_code = $openssl_error_list[1];
1125
		}
1126
1127
		return $openssl_error_code;
1128
	}
1129
1130
	/**
1131
	 * Extract the intermediate certificates from the signed email.
1132
	 * Uses openssl_pkcs7_verify to extract the PKCS#7 blob and then converts the PKCS#7 blob to
1133
	 * X509 certificates using openssl_pkcs7_read.
1134
	 *
1135
	 * @param string $emlfile - the s/mime message
1136
	 *
1137
	 * @return array a list of extracted intermediate certificates
1138
	 */
1139
	public function extractCAs($emlfile) {
1140
		$cas = [];
1141
		$certfile = tempnam(sys_get_temp_dir(), true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $prefix of tempnam(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1141
		$certfile = tempnam(sys_get_temp_dir(), /** @scrutinizer ignore-type */ true);
Loading history...
1142
		$outfile = tempnam(sys_get_temp_dir(), true);
1143
		$p7bfile = tempnam(sys_get_temp_dir(), true);
1144
		openssl_pkcs7_verify($emlfile, PKCS7_NOVERIFY, $certfile);
1145
		openssl_pkcs7_verify($emlfile, PKCS7_NOVERIFY, $certfile, [], $certfile, $outfile, $p7bfile);
1146
1147
		$p7b = file_get_contents($p7bfile);
1148
1149
		openssl_pkcs7_read($p7b, $cas);
1150
		unlink($certfile);
1151
		unlink($outfile);
1152
		unlink($p7bfile);
1153
1154
		return $cas;
1155
	}
1156
1157
	/**
1158
	 * Imports certificate in the MAPI Root Associated Folder.
1159
	 *
1160
	 * Private key, always insert certificate
1161
	 * Public key, check if we already have one stored
1162
	 *
1163
	 * @param string $cert     certificate body as a string
1164
	 * @param mixed  $certData an array with the parsed certificate data
1165
	 * @param string $type     certificate type, default 'public'
1166
	 * @param bool   $force    force import the certificate even though we have one already stored in the MAPI Store.
1167
	 *                         FIXME: remove $force in the future and move the check for newer certificate in this function.
1168
	 */
1169
	public function importCertificate($cert, $certData, $type = 'public', $force = false) {
1170
		$certEmail = getCertEmail($certData);
1171
		if ($this->pubcertExists($certEmail) && !$force && $type !== 'private') {
1172
			return;
1173
		}
1174
		$issued_by = "";
1175
		foreach (array_keys($certData['issuer']) as $key) {
1176
			$issued_by .= $key . '=' . $certData['issuer'][$key] . "\n";
1177
		}
1178
1179
		$root = mapi_msgstore_openentry($this->getStore());
1180
		$assocMessage = mapi_folder_createmessage($root, MAPI_ASSOCIATED);
1181
		// TODO: write these properties down.
1182
		mapi_setprops($assocMessage, [
1183
			PR_SUBJECT => $certEmail,
1184
			PR_MESSAGE_CLASS => $type == 'public' ? 'WebApp.Security.Public' : 'WebApp.Security.Private',
1185
			PR_MESSAGE_DELIVERY_TIME => $certData['validTo_time_t'],
1186
			PR_CLIENT_SUBMIT_TIME => $certData['validFrom_time_t'],
1187
			PR_SENDER_NAME => $certData['serialNumber'], // serial
1188
			PR_SENDER_EMAIL_ADDRESS => $issued_by, // Issuer To
1189
			PR_SUBJECT_PREFIX => '',
1190
			PR_RECEIVED_BY_NAME => $this->fingerprint_cert($cert, 'sha1'), // SHA1 Fingerprint
1191
			PR_INTERNET_MESSAGE_ID => $this->fingerprint_cert($cert), // MD5 FingerPrint
1192
		]);
1193
		// Save attachment
1194
		$msgBody = base64_encode($cert);
1195
		$stream = mapi_openproperty($assocMessage, PR_BODY, IID_IStream, 0, MAPI_CREATE | MAPI_MODIFY);
1196
		mapi_stream_setsize($stream, strlen($msgBody));
1197
		mapi_stream_write($stream, $msgBody);
1198
		mapi_stream_commit($stream);
1199
		mapi_message_savechanges($assocMessage);
1200
	}
1201
1202
	/**
1203
	 * Function which returns the fingerprint (hash) of the certificate.
1204
	 *
1205
	 * @param string $hash optional hash algorithm
1206
	 * @param mixed  $body
1207
	 */
1208
	public function fingerprint_cert($body, $hash = 'md5') {
1209
		// TODO: Note for PHP > 5.6 we can use openssl_x509_fingerprint
1210
		$body = str_replace('-----BEGIN CERTIFICATE-----', '', $body);
1211
		$body = str_replace('-----END CERTIFICATE-----', '', $body);
1212
		$body = base64_decode($body);
1213
1214
		if ($hash === 'sha1') {
1215
			$fingerprint = sha1($body);
1216
		}
1217
		else {
1218
			$fingerprint = md5($body);
1219
		}
1220
1221
		// Format 1000AB as 10:00:AB
1222
		return strtoupper(implode(':', str_split($fingerprint, 2)));
0 ignored issues
show
Bug introduced by
It seems like str_split($fingerprint, 2) can also be of type true; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1222
		return strtoupper(implode(':', /** @scrutinizer ignore-type */ str_split($fingerprint, 2)));
Loading history...
1223
	}
1224
1225
	/**
1226
	 * Retrieve the GAB User.
1227
	 *
1228
	 * FIXME: ideally this would be a public function in grommunio Web.
1229
	 *
1230
	 * @param string $email the email address of the user
1231
	 *
1232
	 * @return mixed $user boolean if false else MAPIObject
1233
	 */
1234
	public function getGABUser($email) {
1235
		$addrbook = $GLOBALS["mapisession"]->getAddressbook();
1236
		$userArr = [[PR_DISPLAY_NAME => $email]];
1237
		$user = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $user is dead and can be removed.
Loading history...
1238
1239
		try {
1240
			$user = mapi_ab_resolvename($addrbook, $userArr, EMS_AB_ADDRESS_LOOKUP);
1241
			$user = mapi_ab_openentry($addrbook, $user[0][PR_ENTRYID]);
1242
		}
1243
		catch (MAPIException $e) {
1244
			$e->setHandled();
1245
		}
1246
1247
		return $user;
1248
	}
1249
1250
	/**
1251
	 * Retrieve the PR_EMS_AB_X509_CERT.
1252
	 *
1253
	 * @param MAPIObject $user the GAB user
0 ignored issues
show
Bug introduced by
The type MAPIObject was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1254
	 *
1255
	 * @return string $cert the certificate, empty if not found
1256
	 */
1257
	public function getGABCert($user) {
1258
		$cert = '';
1259
		$userCertArray = mapi_getprops($user, [PR_EMS_AB_X509_CERT]);
1260
		if (isset($userCertArray[PR_EMS_AB_X509_CERT])) {
1261
			$cert = der2pem($userCertArray[PR_EMS_AB_X509_CERT][0]);
1262
		}
1263
1264
		return $cert;
1265
	}
1266
1267
	/**
1268
	 * Called when the core Settings class is initialized and ready to accept sysadmin default
1269
	 * settings. Registers the sysadmin defaults for the example plugin.
1270
	 *
1271
	 * @param mixed $data Reference to the data of the triggered hook
1272
	 */
1273
	public function onBeforeSettingsInit(&$data) {
1274
		$data['settingsObj']->addSysAdminDefaults([
1275
			'zarafa' => [
1276
				'v1' => [
1277
					'plugins' => [
1278
						'smime' => [
1279
							'enable' => defined('PLUGIN_SMIME_USER_DEFAULT_ENABLE_SMIME') && PLUGIN_SMIME_USER_DEFAULT_ENABLE_SMIME,
1280
							'passphrase_cache' => defined('PLUGIN_SMIME_PASSPHRASE_REMEMBER_BROWSER') && PLUGIN_SMIME_PASSPHRASE_REMEMBER_BROWSER,
1281
						],
1282
					],
1283
				],
1284
			],
1285
		]);
1286
	}
1287
1288
	/**
1289
	 * Get sender structure of the MAPI Message.
1290
	 *
1291
	 * @param mapimessage $mapiMessage MAPI Message resource from which we need to get the sender
0 ignored issues
show
Bug introduced by
The type mapimessage was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1292
	 *
1293
	 * @return array with properties
1294
	 */
1295
	public function getSenderAddress($mapiMessage) {
1296
		if (method_exists($GLOBALS['operations'], 'getSenderAddress')) {
1297
			return $GLOBALS["operations"]->getSenderAddress($mapiMessage);
1298
		}
1299
1300
		$messageProps = mapi_getprops($mapiMessage, [PR_SENT_REPRESENTING_ENTRYID, PR_SENDER_ENTRYID]);
1301
		$senderEntryID = $messageProps[PR_SENT_REPRESENTING_ENTRYID] ?? $messageProps[PR_SENDER_ENTRYID];
1302
1303
		try {
1304
			$senderUser = mapi_ab_openentry($GLOBALS["mapisession"]->getAddressbook(), $senderEntryID);
1305
			if ($senderUser) {
1306
				$userprops = mapi_getprops($senderUser, [PR_ADDRTYPE, PR_DISPLAY_NAME, PR_EMAIL_ADDRESS, PR_SMTP_ADDRESS, PR_OBJECT_TYPE, PR_RECIPIENT_TYPE, PR_DISPLAY_TYPE, PR_DISPLAY_TYPE_EX, PR_ENTRYID]);
1307
1308
				$senderStructure = [];
1309
				$senderStructure["props"]['entryid'] = isset($userprops[PR_ENTRYID]) ? bin2hex((string) $userprops[PR_ENTRYID]) : '';
1310
				$senderStructure["props"]['display_name'] = $userprops[PR_DISPLAY_NAME] ?? '';
1311
				$senderStructure["props"]['email_address'] = $userprops[PR_EMAIL_ADDRESS] ?? '';
1312
				$senderStructure["props"]['smtp_address'] = $userprops[PR_SMTP_ADDRESS] ?? '';
1313
				$senderStructure["props"]['address_type'] = $userprops[PR_ADDRTYPE] ?? '';
1314
				$senderStructure["props"]['object_type'] = $userprops[PR_OBJECT_TYPE];
1315
				$senderStructure["props"]['recipient_type'] = MAPI_TO;
1316
				$senderStructure["props"]['display_type'] = $userprops[PR_DISPLAY_TYPE] ?? MAPI_MAILUSER;
1317
				$senderStructure["props"]['display_type_ex'] = $userprops[PR_DISPLAY_TYPE_EX] ?? MAPI_MAILUSER;
1318
			}
1319
		}
1320
		catch (MAPIException $e) {
1321
			error_log(sprintf("[smime] getSenderAddress(): Exception %s", $e));
1322
		}
1323
1324
		return $senderStructure;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $senderStructure does not seem to be defined for all execution paths leading up to this point.
Loading history...
1325
	}
1326
}
1327