mail/tests/Unit/Service/SmimeServiceTest.php

461 строка
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Tests\Service;
use ChristophWurst\Nextcloud\Testing\TestCase;
use Horde_Imap_Client_Data_Envelope;
use Horde_Imap_Client_Data_Fetch;
use Horde_Mime_Headers;
use Horde_Mime_Headers_ContentParam_ContentType;
use Horde_Mime_Part;
use OCA\Mail\Address;
use OCA\Mail\AddressList;
use OCA\Mail\Db\SmimeCertificate;
use OCA\Mail\Db\SmimeCertificateMapper;
use OCA\Mail\Model\SmimeCertificateInfo;
use OCA\Mail\Model\SmimeCertificatePurposes;
use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\ICertificateManager;
use OCP\ITempManager;
use OCP\Security\ICrypto;
use PHPUnit\Framework\MockObject\MockObject;
class SmimeServiceTest extends TestCase {
private $tempFiles = [];
/** @var ITempManager|MockObject */
private $tempManager;
/** @var ICertificateManager|MockObject */
private $certificateManager;
/** @var ICrypto|MockObject */
private $crypto;
/** @var SmimeCertificateMapper|MockObject */
private $certificateMapper;
/** @var ITimeFactory|MockObject */
private $timeFactory;
/** @var SmimeService&MockObject */
private $smimeService;
protected function setUp(): void {
parent::setUp();
$this->tempManager = $this->createMock(ITempManager::class);
$this->certificateManager = $this->createMock(ICertificateManager::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->certificateMapper = $this->createMock(SmimeCertificateMapper::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->smimeService = new SmimeService(
$this->tempManager,
$this->certificateManager,
$this->crypto,
$this->certificateMapper,
$this->timeFactory
);
}
protected function tearDown(): void {
parent::tearDown();
foreach ($this->tempFiles as $tempFile) {
unlink($tempFile);
}
$this->tempFiles = [];
}
private function getTestCertificate(string $emailAddress, string $userId = 'user'): SmimeCertificate {
$rawCert = file_get_contents(__DIR__ . "/../../data/smime-certs/{$emailAddress}.crt");
$rawKey = file_get_contents(__DIR__ . "/../../data/smime-certs/{$emailAddress}.key");
$certificate = new SmimeCertificate();
$certificate->setId(42);
$certificate->setUserId($userId);
$certificate->setEmailAddress($emailAddress);
$certificate->setCertificate($rawCert);
$certificate->setPrivateKey($rawKey);
return $certificate;
}
private function createTempFile(): string {
$n = count($this->tempFiles);
$tempFile = "/tmp/mail-smime-service-temp-{$n}";
touch($tempFile);
$this->tempFiles[] = $tempFile;
return $tempFile;
}
public function testDecryptMimePartText() {
$encryptedMessage = file_get_contents(__DIR__ . '/../../data/encrypted-message.txt');
$decryptedBody = file_get_contents(__DIR__ . '/../../data/decrypted-message-body.txt');
$certificate = $this->getTestCertificate('user@imap.localhost');
$this->crypto->expects(self::exactly(2))
->method('decrypt')
->willReturnMap([
[$certificate->getCertificate(), '', $certificate->getCertificate()],
[$certificate->getPrivateKey(), '', $certificate->getPrivateKey()],
]);
$this->tempManager->expects(self::exactly(3))
->method('getTemporaryFile')
->willReturnCallback(function () {
return $this->createTempFile();
});
$this->certificateManager->expects(self::once())
->method('getAbsoluteBundlePath')
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
$this->assertEquals(
$decryptedBody,
$this->smimeService
->decryptMimePartText($encryptedMessage, $certificate)
->getDecryptedMessage(),
);
}
public function testDecryptDataFetch(): void {
$encryptedMessage = file_get_contents(__DIR__ . '/../../data/encrypted-message.txt');
$decryptedBody = file_get_contents(__DIR__ . '/../../data/decrypted-message-body.txt');
$message = $this->createMock(Horde_Imap_Client_Data_Fetch::class);
$message->expects(self::once())
->method('getFullMsg')
->willReturn($encryptedMessage);
$headers = new Horde_Mime_Headers();
$contentType = new Horde_Mime_Headers_ContentParam_ContentType('', 'application/pkcs7-mime');
$contentType['smime-type'] = 'enveloped-data';
$headers['content-type'] = $contentType;
$message->expects(self::once())
->method('getHeaderText')
->with('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
->willReturn($headers);
$envelope = new Horde_Imap_Client_Data_Envelope();
$envelope->to = AddressList::parse('user@imap.localhost')->toHorde();
$message->expects(self::once())
->method('getEnvelope')
->willReturn($envelope);
$certificate = $this->getTestCertificate('user@imap.localhost');
$this->certificateMapper->expects(self::once())
->method('findAllByEmailAddress')
->with('user', 'user@imap.localhost')
->willReturn([$certificate]);
$this->crypto->expects(self::exactly(2))
->method('decrypt')
->willReturnMap([
[$certificate->getCertificate(), '', $certificate->getCertificate()],
[$certificate->getPrivateKey(), '', $certificate->getPrivateKey()],
]);
$this->tempManager->expects(self::exactly(3))
->method('getTemporaryFile')
->willReturnCallback(function () {
return $this->createTempFile();
});
$this->certificateManager->expects(self::once())
->method('getAbsoluteBundlePath')
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
$this->assertEquals(
$decryptedBody,
$this->smimeService->decryptDataFetch($message, 'user')->getDecryptedMessage(),
);
}
public function testDecryptDataFetchWithOpaqueSignedData(): void {
$encryptedMessage = file_get_contents(__DIR__ . '/../../data/encrypted-signed-opaque-message.txt');
$decryptedBody = file_get_contents(__DIR__ . '/../../data/decrypted-signed-opaque-message-body.txt');
$message = $this->createMock(Horde_Imap_Client_Data_Fetch::class);
$message->expects(self::once())
->method('getFullMsg')
->willReturn($encryptedMessage);
$headers = new Horde_Mime_Headers();
$contentType = new Horde_Mime_Headers_ContentParam_ContentType('', 'application/pkcs7-mime');
$contentType['smime-type'] = 'enveloped-data';
$headers['content-type'] = $contentType;
$message->expects(self::once())
->method('getHeaderText')
->with('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
->willReturn($headers);
$envelope = new Horde_Imap_Client_Data_Envelope();
$envelope->to = AddressList::parse('user@imap.localhost')->toHorde();
$message->expects(self::once())
->method('getEnvelope')
->willReturn($envelope);
$certificate = $this->getTestCertificate('user@imap.localhost');
$this->certificateMapper->expects(self::once())
->method('findAllByEmailAddress')
->with('user', 'user@imap.localhost')
->willReturn([$certificate]);
$this->crypto->expects(self::exactly(2))
->method('decrypt')
->willReturnMap([
[$certificate->getCertificate(), '', $certificate->getCertificate()],
[$certificate->getPrivateKey(), '', $certificate->getPrivateKey()],
]);
$this->tempManager->expects(self::exactly(5))
->method('getTemporaryFile')
->willReturnCallback(function () {
return $this->createTempFile();
});
$this->certificateManager->expects(self::exactly(2))
->method('getAbsoluteBundlePath')
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
$this->assertEquals(
$decryptedBody,
$this->smimeService->decryptDataFetch($message, 'user')->getDecryptedMessage(),
);
}
public function testDecryptDataFetchWithRegularMessage(): void {
$messageText = file_get_contents(__DIR__ . '/../../data/mail-message-123.txt');
$message = $this->createMock(Horde_Imap_Client_Data_Fetch::class);
$message->expects(self::once())
->method('getFullMsg')
->willReturn($messageText);
$headers = new Horde_Mime_Headers();
$headers['content-type'] = new Horde_Mime_Headers_ContentParam_ContentType('', 'multipart/alternative');
$message->expects(self::once())
->method('getHeaderText')
->with('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
->willReturn($headers);
$this->assertEquals(
$messageText,
$this->smimeService->decryptDataFetch($message, 'user')->getDecryptedMessage(),
);
}
public function provideIsEncryptedData(): array {
return [
['application/pkcs7-mime', ['smime-type' => 'enveloped-data'], true],
['application/pkcs7-mime', ['smime-type' => 'signed-data'], false],
['application/pkcs7-mime', [], false], // Should not happen in real life but who knows
['multipart/alternative', [], false],
['', [], false],
];
}
/**
* @dataProvider provideIsEncryptedData
*/
public function testIsEncrypted(?string $contentType,
array $contentTypeParams,
bool $expectedResult): void {
$message = $this->createMock(Horde_Imap_Client_Data_Fetch::class);
$headers = new Horde_Mime_Headers();
$contentType = new Horde_Mime_Headers_ContentParam_ContentType('', $contentType);
foreach ($contentTypeParams as $key => $value) {
$contentType[$key] = $value;
}
$headers['content-type'] = $contentType;
$message->expects(self::once())
->method('getHeaderText')
->with('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
->willReturn($headers);
$this->assertEquals($expectedResult, $this->smimeService->isEncrypted($message));
}
public function testIsEncryptedWhenHeaderIsMissing(): void {
$message = $this->createMock(Horde_Imap_Client_Data_Fetch::class);
$headers = new Horde_Mime_Headers();
$message->expects(self::once())
->method('getHeaderText')
->with('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE)
->willReturn($headers);
$this->assertFalse($this->smimeService->isEncrypted($message));
}
public function testExtractSignedContent(): void {
$signedMessage = file_get_contents(__DIR__ . '/../../data/signed-opaque-message.txt');
$verifiedContent = file_get_contents(__DIR__ . '/../../data/decrypted-signed-opaque-message-body.txt');
$this->tempManager->expects(self::exactly(2))
->method('getTemporaryFile')
->willReturnOnConsecutiveCalls(
$this->createTempFile(),
$this->createTempFile(),
);
$this->certificateManager->expects(self::once())
->method('getAbsoluteBundlePath')
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
$this->assertEquals(
$verifiedContent,
$this->smimeService->extractSignedContent($signedMessage),
);
}
public function testFindCertificatesByAddressList(): void {
$addressJohn = Address::fromRaw('John', 'john@foo.bar');
$addressJane = Address::fromRaw('Jane', 'jane@foo.bar');
$addressList = new AddressList([
$addressJohn,
$addressJane
]);
$certificateJohn = new SmimeCertificate();
$certificateJohn->setId(1);
$certificateJohn->setUserId('100');
$certificateJohn->setCertificate('10101010');
$certificateJane = new SmimeCertificate();
$certificateJane->setId(2);
$certificateJane->setUserId('100');
$certificateJane->setCertificate('10101010');
$this->certificateMapper
->method('findAllByEmailAddresses')
->with(100, ['john@foo.bar', 'jane@foo.bar'])
->willReturn([$certificateJohn, $certificateJane]);
$certificates = $this->smimeService->findCertificatesByAddressList($addressList, '100');
$this->assertCount(2, $certificates);
}
public function testEncryptMimePartText() {
$certificateDomainTld = $this->getTestCertificate('user@domain.tld');
$certificateImapLocalhost = $this->getTestCertificate('user@imap.localhost');
$certificates = [
$certificateDomainTld,
$certificateImapLocalhost
];
$mailBody = file_get_contents(__DIR__ . '/../../../tests/data/mime-html-image.txt');
$mimePart = new \Horde_Mime_Part();
$mimePart->setContents($mailBody);
$this->crypto
->method('decrypt')
->will($this->returnArgument(0));
$this->tempManager
->method('getTemporaryFile')
->willReturnCallback(function () {
return $this->createTempFile();
});
$encryptedMimePart = $this->smimeService->encryptMimePart($mimePart, $certificates);
$encryptedText = $encryptedMimePart->toString([
'canonical' => true,
'headers' => true,
]);
$decryptedTextImapLocalhost = $this->smimeService
->decryptMimePartText($encryptedText, $certificateImapLocalhost)
->getDecryptedMessage();
$decryptedMimePartImapLocalhost = Horde_Mime_Part::parseMessage($decryptedTextImapLocalhost, [
'forcemime' => true,
]);
$decryptedTextDomainTld = $this->smimeService
->decryptMimePartText($encryptedText, $certificateDomainTld)
->getDecryptedMessage();
$decryptedMimePartDomainTld = Horde_Mime_Part::parseMessage($decryptedTextDomainTld, [
'forcemime' => true,
]);
$this->assertEquals($mimePart->getContents(), $decryptedMimePartImapLocalhost->getContents());
$this->assertEquals($mimePart->getContents(), $decryptedMimePartDomainTld->getContents());
}
public function provideParseCertificateData(): array {
return [
[
$this->getTestCertificate('user@imap.localhost'),
new SmimeCertificateInfo(
'user',
'user@imap.localhost',
4862017735,
new SmimeCertificatePurposes(true, true),
true,
),
],
[
$this->getTestCertificate('cn-only@imap.localhost'),
new SmimeCertificateInfo(
'cn-only',
'cn-only@imap.localhost',
4862017727,
new SmimeCertificatePurposes(true, true),
true,
),
],
[
$this->getTestCertificate('user@domain.tld'),
new SmimeCertificateInfo(
'user',
'user@domain.tld',
4862017705,
new SmimeCertificatePurposes(true, true),
false,
),
],
[ // Full chain: Leaf -> Intermediate CA -> CA
$this->getTestCertificate('chain@imap.localhost'),
new SmimeCertificateInfo(
'chain',
'chain@imap.localhost',
4870519697,
new SmimeCertificatePurposes(true, true),
true,
),
],
[ // Partial chain: Leaf -> Intermediate CA
$this->getTestCertificate('chain-partial@imap.localhost'),
new SmimeCertificateInfo(
'chain',
'chain@imap.localhost',
4870519697,
new SmimeCertificatePurposes(true, true),
true,
),
],
[ // Leaf only
$this->getTestCertificate('chain-leaf-only@imap.localhost'),
new SmimeCertificateInfo(
'chain',
'chain@imap.localhost',
4870519697,
new SmimeCertificatePurposes(true, true),
false,
),
],
];
}
/**
* @dataProvider provideParseCertificateData
*/
public function testParseCertificate(SmimeCertificate $certificate,
SmimeCertificateInfo $expected): void {
$this->certificateManager->expects(self::once())
->method('getAbsoluteBundlePath')
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
$this->tempManager->expects(self::once())
->method('getTemporaryFile')
->willReturnCallback(fn () => $this->createTempFile());
$this->assertEquals(
$expected,
$this->smimeService->parseCertificate($certificate->getCertificate()),
);
}
}