-
Notifications
You must be signed in to change notification settings - Fork 256
/
SmimeService.php
613 lines (549 loc) Β· 20 KB
/
SmimeService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service;
use Exception;
use Horde_Imap_Client;
use Horde_Imap_Client_Data_Fetch;
use Horde_Imap_Client_Fetch_Query;
use Horde_Mail_Rfc822_Address;
use Horde_Mime_Exception;
use Horde_Mime_Headers;
use Horde_Mime_Headers_ContentParam_ContentType;
use Horde_Mime_Part;
use OCA\Mail\AddressList;
use OCA\Mail\Db\SmimeCertificate;
use OCA\Mail\Db\SmimeCertificateMapper;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeCertificateParserException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\Exception\SmimeEncryptException;
use OCA\Mail\Exception\SmimeSignException;
use OCA\Mail\Model\EnrichedSmimeCertificate;
use OCA\Mail\Model\SmimeCertificateInfo;
use OCA\Mail\Model\SmimeCertificatePurposes;
use OCA\Mail\Model\SmimeDecryptionResult;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\ICertificateManager;
use OCP\ITempManager;
use OCP\Security\ICrypto;
class SmimeService {
private ITempManager $tempManager;
private ICertificateManager $certificateManager;
private ICrypto $crypto;
private SmimeCertificateMapper $certificateMapper;
private ITimeFactory $timeFactory;
public function __construct(ITempManager $tempManager,
ICertificateManager $certificateManager,
ICrypto $crypto,
SmimeCertificateMapper $certificateMapper,
ITimeFactory $timeFactory) {
$this->tempManager = $tempManager;
$this->certificateManager = $certificateManager;
$this->crypto = $crypto;
$this->certificateMapper = $certificateMapper;
$this->timeFactory = $timeFactory;
}
/**
* Attempt to verify a message signed with S/MIME.
*
* Requires the openssl extension.
*
* @param string $message Whole message including all headers and parts as stored on IMAP
* @return bool
*/
public function verifyMessage(string $message): bool {
// Ideally, we should use the more modern openssl cms module as it is a superset of the
// smime/pkcs7 module. Unfortunately, it is only supported since php 8.
// Ref https://www.php.net/manual/en/function.openssl-cms-verify.php
$messageTemp = $this->tempManager->getTemporaryFile();
$messageTempHandle = fopen($messageTemp, 'wb');
fwrite($messageTempHandle, $message);
fclose($messageTempHandle);
/** @psalm-suppress NullArgument */
$valid = openssl_pkcs7_verify(
$messageTemp,
0,
null,
[$this->certificateManager->getAbsoluteBundlePath()],
);
if (is_int($valid)) {
// OpenSSL error
return false;
}
return $valid;
}
/**
* Attempt to extract the signed content from a signed S/MIME message.
* Can be used to extract opaque signed content even if the signature itself can't be verified.
*
* Warning: This method does not attempt to verify the signature.
*
* Requires the openssl extension.
*
* @param string $message Whole message including all headers and parts as stored on IMAP
* @return string Signed content
*
* @throws ServiceException If no signed content can be extracted
*/
public function extractSignedContent(string $message): string {
// Ideally, we should use the more modern openssl cms module as it is a superset of the
// smime/pkcs7 module. Unfortunately, it is only supported since php 8.
// Ref https://www.php.net/manual/en/function.openssl-cms-verify.php
$verifiedContentTemp = $this->tempManager->getTemporaryFile();
$messageTemp = $this->tempManager->getTemporaryFile();
$messageTempHandle = fopen($messageTemp, 'wb');
fwrite($messageTempHandle, $message);
fclose($messageTempHandle);
/** @psalm-suppress NullArgument */
$valid = openssl_pkcs7_verify(
$messageTemp,
PKCS7_NOSIGS | PKCS7_NOVERIFY,
null,
[$this->certificateManager->getAbsoluteBundlePath()],
null,
$verifiedContentTemp,
);
if (is_int($valid)) {
// OpenSSL error
throw new ServiceException('Failed to extract signed content');
}
$verifiedContent = file_get_contents($verifiedContentTemp);
if ($verifiedContent === false) {
throw new ServiceException('Could not read back verified content');
}
return $verifiedContent;
}
/**
* Parse a X509 certificate.
*
* @param string $certificate X509 certificate encoded as PEM
* @return SmimeCertificateInfo Metadata of the certificate
*
* @throws SmimeCertificateParserException If the certificate can't be parsed
*/
public function parseCertificate(string $certificate): SmimeCertificateInfo {
// TODO: support parsing email addresses from SANs
// TODO: support multiple email addresses per certificate
$certificateData = openssl_x509_parse($certificate);
if ($certificateData === false) {
throw new SmimeCertificateParserException('Could not parse certificate');
}
if (!isset($certificateData['subject']['emailAddress'])
&& !isset($certificateData['subject']['CN'])) {
throw new SmimeCertificateParserException('Certificate does not contain an email address');
}
$purposes = new SmimeCertificatePurposes(false, false);
foreach ($certificateData['purposes'] as $purpose) {
[$state, $_, $name] = $purpose;
if ($name === 'smimesign') {
$purposes->setSign((bool)$state);
} elseif ($name === 'smimeencrypt') {
$purposes->setEncrypt((bool)$state);
}
}
$decryptedCertificateFile = $this->tempManager->getTemporaryFile();
file_put_contents($decryptedCertificateFile, $certificate);
$caBundle = [$this->certificateManager->getAbsoluteBundlePath()];
return new SmimeCertificateInfo(
$certificateData['subject']['CN'] ?? null,
$certificateData['subject']['emailAddress'] ?? $certificateData['subject']['CN'],
$certificateData['validTo_time_t'],
$purposes,
openssl_x509_checkpurpose($certificate, X509_PURPOSE_ANY, $caBundle, $decryptedCertificateFile) === true,
);
}
/**
* Enrich S/MIME certificate from the database with additional information.
*
* @param SmimeCertificate $certificate
* @return EnrichedSmimeCertificate
*
* @throws ServiceException If decrypting the certificate fails
* @throws SmimeCertificateParserException If parsing the certificate fails
*/
public function enrichCertificate(SmimeCertificate $certificate): EnrichedSmimeCertificate {
try {
$decryptedCertificate = $this->crypto->decrypt($certificate->getCertificate());
} catch (Exception $e) {
throw new ServiceException(
'Failed to decrypt certificate: ' . $e->getMessage(),
0,
$e,
);
}
return new EnrichedSmimeCertificate(
$certificate,
$this->parseCertificate($decryptedCertificate),
);
}
/**
* Check if the given private key corresponds to the given certificate.
*
* @param string $certificate X509 certificate encoded as PEM
* @param string $privateKey Private key encoded as PEM
* @return bool True if the private key matches the certificate, false otherwise or if the private key is protected by a passphrase
*/
public function checkPrivateKey(string $certificate, string $privateKey): bool {
return openssl_x509_check_private_key($certificate, $privateKey);
}
/**
* Get a single S/MIME certificate by id.
*
* @param int $certificateId
* @param string $userId
* @return SmimeCertificate
*
* @throws DoesNotExistException
*/
public function findCertificate(int $certificateId, string $userId): SmimeCertificate {
return $this->certificateMapper->find($certificateId, $userId);
}
/**
* Get all S/MIME certificates belonging to an email address.
*
* @param string $emailAddress
* @param string $userId
* @return SmimeCertificate[]
*
* @throws ServiceException If the database query fails
*/
public function findCertificatesByEmailAddress(string $emailAddress,
string $userId): array {
try {
return $this->certificateMapper->findAllByEmailAddress($userId, $emailAddress);
} catch (\OCP\DB\Exception $e) {
throw new ServiceException(
'Failed to fetch certificates by email address: ' . $e->getMessage(),
0,
$e,
);
}
}
/**
* Get all S/MIME certificates belonging to an address list
*
* @param AddressList $addressList
* @param string $userId
* @return SmimeCertificate[]
*
* @throws ServiceException If the database query fails or converting an email address failed
*/
public function findCertificatesByAddressList(AddressList $addressList, string $userId): array {
$emailAddresses = [];
foreach ($addressList->iterate() as $address) {
try {
$emailAddress = $address->getEmail();
} catch (\Exception $e) {
throw new ServiceException($e->getMessage(), 0, $e);
}
if (!empty($emailAddress)) {
$emailAddresses[] = $emailAddress;
}
}
try {
return $this->certificateMapper->findAllByEmailAddresses($userId, $emailAddresses);
} catch (\OCP\DB\Exception $e) {
throw new ServiceException('Failed to fetch certificates by email addresses: ' . $e->getMessage(), 0, $e);
}
}
/**
* Find all S/MIME certificates of the given user.
*
* @param string $userId
* @return SmimeCertificate[]
*
* @throws ServiceException
*/
public function findAllCertificates(string $userId): array {
return $this->certificateMapper->findAll($userId);
}
/**
* Delete an S/MIME certificate by its id.
*
* @param int $id
* @param string $userId
* @return void
*
* @throws DoesNotExistException
*/
public function deleteCertificate(int $id, string $userId): void {
$certificate = $this->certificateMapper->find($id, $userId);
$this->certificateMapper->delete($certificate);
}
/**
* Store an S/MIME certificate in the database.
*
* @param string $userId
* @param string $certificateData
* @param ?string $privateKeyData
* @return SmimeCertificate
*
* @throws ServiceException
*/
public function createCertificate(string $userId,
string $certificateData,
?string $privateKeyData): SmimeCertificate {
$emailAddress = $this->parseCertificate($certificateData)->getEmailAddress();
$certificate = new SmimeCertificate();
$certificate->setUserId($userId);
$certificate->setEmailAddress($emailAddress);
$certificate->setCertificate($this->crypto->encrypt($certificateData));
if ($privateKeyData !== null) {
if (!$this->checkPrivateKey($certificateData, $privateKeyData)) {
throw new ServiceException('Private key does not match certificate or is protected by a passphrase');
}
$certificate->setPrivateKey($this->crypto->encrypt($privateKeyData));
}
return $this->certificateMapper->insert($certificate);
}
/**
* Sign a MIME part using the given certificate and private key.
*
* @param Horde_Mime_Part $part
* @param SmimeCertificate $certificate
* @return Horde_Mime_Part New MIME part containing the signed message and the signature
*
* @throws SmimeSignException If signing the message fails
* @throws ServiceException If decrypting the certificate or private key fails or the private key is missing
*/
public function signMimePart(Horde_Mime_Part $part,
SmimeCertificate $certificate): Horde_Mime_Part {
if ($certificate->getPrivateKey() === null) {
throw new ServiceException('Certificate does not have a private key');
}
try {
$decryptedCertificate = $this->crypto->decrypt($certificate->getCertificate());
$decryptedKey = $this->crypto->decrypt($certificate->getPrivateKey());
} catch (Exception $e) {
throw new ServiceException(
'Failed to decrypt certificate or private key: ' . $e->getMessage(),
0,
$e,
);
}
$decryptedCertificateFile = $this->tempManager->getTemporaryFile();
file_put_contents($decryptedCertificateFile, $decryptedCertificate);
$inPath = $this->tempManager->getTemporaryFile();
$outPath = $this->tempManager->getTemporaryFile();
file_put_contents($inPath, $part->toString([
'canonical' => true,
'headers' => true,
]));
if (!openssl_pkcs7_sign($inPath, $outPath, $decryptedCertificate, $decryptedKey, null, PKCS7_DETACHED, $decryptedCertificateFile)) {
throw new SmimeSignException('Failed to sign MIME part');
}
try {
$parsedPart = Horde_Mime_Part::parseMessage(file_get_contents($outPath), [
'forcemime' => true,
]);
} catch (Horde_Mime_Exception $e) {
throw new SmimeSignException(
'Failed to parse signed MIME part: ' . $e->getMessage(),
0,
$e,
);
}
// Not required but makes sense. Otherwise, it's just a generic MIME format message.
$parsedPart->setContents("This is a cryptographically signed message in MIME format.\n");
// Retain signature but replace signed part content with original content
$parsedPart[1] = $part;
return $parsedPart;
}
/**
* Decrypt full text of a MIME message and verify the signed message within.
* This method assumes the given mime part text to be encrypted without checking.
*
* @param string $mimePartText
* @param SmimeCertificate $certificate The certificate needs to contain a private key.
* @return SmimeDecryptionResult Full text of decrypted MIME message and the signature verification status.
*
* @throws ServiceException If the given certificate does not have a private key or can't be decrypted
* @throws SmimeDecryptException If openssl reports an error during decryption
*/
public function decryptMimePartText(string $mimePartText,
SmimeCertificate $certificate): SmimeDecryptionResult {
if ($certificate->getPrivateKey() === null) {
throw new ServiceException('Certificate does not have a private key');
}
try {
$decryptedCertificate = $this->crypto->decrypt($certificate->getCertificate());
$decryptedKey = $this->crypto->decrypt($certificate->getPrivateKey());
} catch (Exception $e) {
throw new ServiceException(
'Failed to decrypt certificate or private key: ' . $e->getMessage(),
0,
$e,
);
}
$inPath = $this->tempManager->getTemporaryFile();
$outPath = $this->tempManager->getTemporaryFile();
file_put_contents($inPath, $mimePartText);
if (!openssl_pkcs7_decrypt($inPath, $outPath, $decryptedCertificate, $decryptedKey)) {
throw new SmimeDecryptException('Failed to decrypt MIME part text');
}
$decryptedMessage = file_get_contents($outPath);
// Handle smime-type="signed-data" as the content is opaque until verified
$headers = Horde_Mime_Headers::parseHeaders($decryptedMessage);
if (!isset($headers['content-type'])) {
// Has no content-typ header -> can't be a signed message
return new SmimeDecryptionResult($decryptedMessage, true, false, false);
}
/** @var Horde_Mime_Headers_ContentParam_ContentType $contentType */
$contentType = $headers['content-type'];
$contentTypeString = "$contentType->ptype/$contentType->stype";
$isSigned = false;
$signatureIsValid = false;
if (($contentTypeString === 'application/pkcs7-mime'
|| $contentTypeString === 'application/x-pkcs7-mime')
&& isset($contentType['smime-type'])
&& $contentType['smime-type'] === 'signed-data') {
// Opaque signed data needs to be extracted and verified
$isSigned = true;
try {
$signatureIsValid = $this->verifyMessage($decryptedMessage);
$decryptedMessage = $this->extractSignedContent($decryptedMessage);
} catch (ServiceException $e) {
throw new ServiceException(
'Failed to extract nested opaque signed data: ' . $e->getMessage(),
0,
$e,
);
}
} elseif ($contentTypeString === 'multipart/signed') {
// Clear signed data just needs to be verified
$isSigned = true;
$signatureIsValid = $this->verifyMessage($decryptedMessage);
}
return new SmimeDecryptionResult(
$decryptedMessage,
true,
$isSigned,
$signatureIsValid,
);
}
/**
* Try to decrypt a raw data fetch from horde and verify the signed message within.
* The fetch needs to contain at least envelope, headerText and fullText.
* See the addDecryptQueries() method.
*
* This method will do nothing to the full text if the message is not encrypted.
* The verification will also be skipped in that case.
*
* @param Horde_Imap_Client_Data_Fetch $message
* @param string $userId
* @return SmimeDecryptionResult
*
* @throws ServiceException
*/
public function decryptDataFetch(Horde_Imap_Client_Data_Fetch $message,
string $userId): SmimeDecryptionResult {
$encryptedText = $message->getFullMsg();
if (!$this->isEncrypted($message)) {
// Verification of a potential signature is up to the caller because it is trivial
return SmimeDecryptionResult::fromPlain($encryptedText);
}
$decryptionResult = null;
$envelope = $message->getEnvelope();
foreach ($envelope->to as $recipient) {
/** @var Horde_Mail_Rfc822_Address $recipient */
$recipientAddress = $recipient->bare_address;
$certs = $this->findCertificatesByEmailAddress(
$recipientAddress,
$userId,
);
foreach ($certs as $cert) {
try {
$decryptionResult = $this->decryptMimePartText($encryptedText, $cert);
} catch (ServiceException | SmimeDecryptException $e) {
// Certificate probably didn't match -> continue
// TODO: filter a real decryption error
// (is hard because openssl doesn't return a proper error code)
continue;
}
}
}
if ($decryptionResult === null) {
throw new ServiceException('Failed to find a suitable S/MIME certificate for decryption');
}
return $decryptionResult;
}
public function addEncryptionCheckQueries(Horde_Imap_Client_Fetch_Query $query,
bool $peek = true): void {
if (!$query->contains(Horde_Imap_Client::FETCH_HEADERTEXT)) {
$query->headerText([
'peek' => $peek,
]);
}
}
public function addDecryptQueries(Horde_Imap_Client_Fetch_Query $query,
bool $peek = true): void {
$this->addEncryptionCheckQueries($query, $peek);
$query->envelope();
if (!$query->contains(Horde_Imap_Client::FETCH_FULLMSG)) {
$query->fullText([
'peek' => $peek,
]);
}
}
public function isEncrypted(Horde_Imap_Client_Data_Fetch $message): bool {
$headers = $message->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE);
if (!isset($headers['content-type'])) {
return false;
}
/** @var Horde_Mime_Headers_ContentParam_ContentType $contentType */
$contentType = $headers['content-type'];
return $contentType->ptype === 'application'
&& str_ends_with($contentType->stype, 'pkcs7-mime')
&& isset($contentType['smime-type'])
&& $contentType['smime-type'] === 'enveloped-data';
}
/**
* Encrypt a MIME part using the given certificates.
*
* @param Horde_Mime_Part $part
* @param SmimeCertificate[] $certificates
* @return Horde_Mime_Part New MIME part containing the encrypted message and the signature
*
* @throws ServiceException If decrypting the certificates fails
* @throws SmimeEncryptException If encrypting the message fails
*/
public function encryptMimePart(Horde_Mime_Part $part, array $certificates): Horde_Mime_Part {
try {
$decryptedCertificates = array_map(function (SmimeCertificate $certificate) {
return $this->crypto->decrypt($certificate->getCertificate());
}, $certificates);
} catch (Exception $e) {
throw new ServiceException('Failed to decrypt certificate: ' . $e->getMessage(), 0, $e);
}
$inPath = $this->tempManager->getTemporaryFile();
$outPath = $this->tempManager->getTemporaryFile();
file_put_contents($inPath, $part->toString([
'canonical' => true,
'headers' => true,
]));
/**
* Content-Type is application/x-pkcs7-mime by default.
* The flag PKCS7_NOOLDMIMETYPE / 0x400 let openssl use application/pkcs7-mime as Content-Type.
* PKCS7_NOOLDMIMETYPE is not available as constant in PHP.
*
* https://github.com/openssl/openssl/blob/9a2f78e14a67eeaadefc77d05f0778fc9684d26c/include/openssl/pkcs7.h.in#L211
* https://github.com/php/php-src/blob/51b70e4414b43a571ebd743d752cf4cbd1556eb5/ext/openssl/openssl_arginfo.h#L572-L580
*/
if (!openssl_pkcs7_encrypt($inPath, $outPath, $decryptedCertificates, [], 0x400, OPENSSL_CIPHER_AES_128_CBC)) {
throw new SmimeEncryptException('Failed to encrypt MIME part');
}
try {
$parsedPart = Horde_Mime_Part::parseMessage(file_get_contents($outPath), [
'forcemime' => true,
]);
} catch (Horde_Mime_Exception $e) {
throw new SmimeEncryptException('Failed to parse signed MIME part: ' . $e->getMessage(), 0, $e);
}
return $parsedPart;
}
}