tempManager = $tempManager; $this->certificateManager = $certificateManager; $this->crypto = $crypto; $this->certificateMapper = $certificateMapper; } /** * 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->getTemporaryFileOrThrow(); $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->getTemporaryFileOrThrow(); $messageTemp = $this->getTemporaryFileOrThrow(); $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->getTemporaryFileOrThrow(); 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->getTemporaryFileOrThrow(); file_put_contents($decryptedCertificateFile, $decryptedCertificate); $inPath = $this->getTemporaryFileOrThrow(); $outPath = $this->getTemporaryFileOrThrow(); file_put_contents($inPath, $part->toString([ 'canonical' => true, 'headers' => true, 'encode' => Horde_Mime_Part::ENCODE_8BIT, ])); if (!openssl_pkcs7_sign($inPath, $outPath, $decryptedCertificate, $decryptedKey, null, PKCS7_DETACHED | PKCS7_BINARY, $decryptedCertificateFile)) { throw new SmimeSignException('Failed to sign MIME part'); } try { $parsedPart = Horde_Mime_Part::parseMessage(file_get_contents($outPath)); } 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->getTemporaryFileOrThrow(); $outPath = $this->getTemporaryFileOrThrow(); 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 SmimeDecryptException('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(fn (SmimeCertificate $certificate) => $this->crypto->decrypt($certificate->getCertificate()), $certificates); } catch (Exception $e) { throw new ServiceException('Failed to decrypt certificate: ' . $e->getMessage(), 0, $e); } $inPath = $this->getTemporaryFileOrThrow(); $outPath = $this->getTemporaryFileOrThrow(); file_put_contents($inPath, $part->toString([ 'canonical' => true, 'headers' => true, 'encode' => Horde_Mime_Part::ENCODE_8BIT, ])); /** * 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; } /** * Create a temporary file and return the path or throw if it could not be created. * * @throws ServiceException If the temporary file could not be created */ private function getTemporaryFileOrThrow(): string { $file = $this->tempManager->getTemporaryFile(); if ($file === false) { throw new ServiceException('Failed to create temporary file'); } return $file; } }