<?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\ICertificateManager;
use OCP\ITempManager;
use OCP\Security\ICrypto;

class SmimeService {
	private ITempManager $tempManager;
	private ICertificateManager $certificateManager;
	private ICrypto $crypto;

	public function __construct(
		ITempManager $tempManager,
		ICertificateManager $certificateManager,
		ICrypto $crypto,
		private SmimeCertificateMapper $certificateMapper,
	) {
		$this->tempManager = $tempManager;
		$this->certificateManager = $certificateManager;
		$this->crypto = $crypto;
	}

	/**
	 * 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');
		if ($messageTempHandle === false) {
			throw new ServiceException('Could not open temp file for writing');
		}
		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');
		if ($messageTempHandle === false) {
			throw new ServiceException('Could not open temp file for writing');
		}
		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 multiple email addresses per certificate

		$certificateData = openssl_x509_parse($certificate);
		if ($certificateData === false) {
			throw new SmimeCertificateParserException('Could not parse certificate');
		}

		// Resolve email address from Subject emailAddress, then SAN (RFC 8550 preferred),
		// then Subject CN only if it looks like an email address.
		$cn = $certificateData['subject']['CN'] ?? null;
		$emailAddress = $certificateData['subject']['emailAddress']
			?? $this->extractEmailFromSan($certificateData)
			?? (($cn !== null && str_contains($cn, '@')) ? $cn : null);

		if ($emailAddress === null) {
			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(
			$cn,
			$emailAddress,
			$certificateData['validTo_time_t'],
			$purposes,
			openssl_x509_checkpurpose($certificate, X509_PURPOSE_ANY, $caBundle, $decryptedCertificateFile) === true,
		);
	}

	/**
	 * Extract the first email address from the Subject Alternative Name extension.
	 *
	 * The subjectAltName string from openssl_x509_parse() is comma-separated with entries
	 * like "email:user@example.com", "DNS:example.com", etc.
	 *
	 * @param array $certificateData Parsed certificate data from openssl_x509_parse()
	 * @return string|null First RFC822 email address found in the SAN, or null if none
	 */
	private function extractEmailFromSan(array $certificateData): ?string {
		$san = $certificateData['extensions']['subjectAltName'] ?? null;
		if ($san === null) {
			return null;
		}

		foreach (explode(',', $san) as $entry) {
			$entry = trim($entry);
			if (stripos($entry, 'email:') === 0) {
				$email = substr($entry, 6);
				if (filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
					return $email;
				}
			}
		}

		return null;
	}

	/**
	 * 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 {
			$signedContent = file_get_contents($outPath);
			if ($signedContent === false) {
				throw new SmimeSignException('Failed to read signed MIME part from temp file');
			}
			$parsedPart = Horde_Mime_Part::parseMessage($signedContent);
		} 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);
		if ($decryptedMessage === false) {
			throw new SmimeDecryptException('Failed to read decrypted content from temp file');
		}

		// 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 If the database query fails
	 * @throws SmimeDecryptException No certificate for decryption found
	 */
	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 {
			$encryptedContent = file_get_contents($outPath);
			if ($encryptedContent === false) {
				throw new SmimeEncryptException('Failed to read encrypted MIME part from temp file');
			}
			$parsedPart = Horde_Mime_Part::parseMessage($encryptedContent, [
				'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;
	}
}
