From 128e2f62540a1d0dadfc6f30b7a3d01990d2452e Mon Sep 17 00:00:00 2001 From: the-djmaze <> Date: Mon, 19 Feb 2024 04:35:37 +0100 Subject: [PATCH] Added signing using S/MIME #259 --- dev/View/Popup/Compose.js | 91 +++++++++++-------- .../libraries/MailSo/Imap/BodyStructure.php | 6 +- .../MailSo/Mime/Enumerations/ContentType.php | 6 ++ .../0.0.0/app/libraries/MailSo/Mime/Part.php | 6 +- .../libraries/RainLoop/Actions/Messages.php | 8 +- .../libraries/snappymail/file/temporary.php | 10 +- .../snappymail/smime/certificate.php | 13 ++- .../libraries/snappymail/smime/openssl.php | 31 ++++--- .../templates/Views/User/PopupsCompose.html | 12 +-- .../templates/Views/User/PopupsIdentity.html | 61 ++++++------- 10 files changed, 143 insertions(+), 101 deletions(-) diff --git a/dev/View/Popup/Compose.js b/dev/View/Popup/Compose.js index 3573ac325..de5f8f93d 100644 --- a/dev/View/Popup/Compose.js +++ b/dev/View/Popup/Compose.js @@ -250,12 +250,16 @@ export class ComposePopupView extends AbstractViewPopup { showBcc: false, showReplyTo: false, - pgpSign: false, + doSign: false, + doEncrypt: false, + canPgpSign: false, - pgpEncrypt: false, canPgpEncrypt: false, canMailvelope: false, + canSMimeSign: false, + canSMimeEncrypt: false, + draftsFolder: '', draftUid: 0, sending: false, @@ -330,6 +334,9 @@ export class ComposePopupView extends AbstractViewPopup { attachmentsInProcessCount: () => this.attachmentsInProcess.length, isDraft: () => this.draftsFolder() && this.draftUid(), + canSign: () => this.canPgpSign() | this.canSMimeSign(), + canEncrypt: () => this.canPgpEncrypt() | this.canSMimeEncrypt(), + identitiesOptions: () => IdentityUserStore.map(item => ({ item: item, @@ -349,9 +356,11 @@ export class ComposePopupView extends AbstractViewPopup { currentIdentity: value => { if (value) { + const smime = !!(value.smimeKey() && value.smimeCertificate()); this.from(value.formattedName()); - this.pgpEncrypt(value.pgpEncrypt()/* || SettingsUserStore.pgpEncrypt()*/); - this.pgpSign(value.pgpSign()/* || SettingsUserStore.pgpSign()*/); + this.doEncrypt(value.pgpEncrypt()/* || SettingsUserStore.pgpEncrypt()*/); + this.doSign(smime || value.pgpSign()/* || SettingsUserStore.pgpSign()*/); + this.canSMimeSign(smime); } }, @@ -361,9 +370,9 @@ export class ComposePopupView extends AbstractViewPopup { value && PgpUserStore.getKeyForSigning(value).then(result => { console.log({ email: value, - canPgpSign:!!result + canPgpSign:result }); - this.canPgpSign(!!result) + this.canPgpSign(result) }); this.initPgpEncrypt(); }, @@ -426,7 +435,7 @@ export class ComposePopupView extends AbstractViewPopup { case 'K': quota *= 1024; } // Issue: can't select signing key -// this.pgpSign(this.pgpSign() || confirm('Sign this message?')); +// this.doSign(this.doSign() || confirm('Sign this message?')); mailvelope.createEditorContainer('#mailvelope-editor', PgpUserStore.mailvelopeKeyring, { // https://mailvelope.github.io/mailvelope/global.html#EditorContainerOptions quota: Math.max(2048, (quota / 1024)) - 48, // (text + attachments) limit in kilobytes @@ -438,7 +447,7 @@ export class ComposePopupView extends AbstractViewPopup { quotedMailHeader: '', // header to be added before the quoted mail keepAttachments: false, // add attachments of quotedMail to editor (default: false) // Issue: can't select signing key - signMsg: this.pgpSign() + signMsg: this.doSign() */ }).then(editor => this.mailvelope = editor); } @@ -1338,8 +1347,8 @@ export class ComposePopupView extends AbstractViewPopup { this.showBcc(false); this.showReplyTo(false); - this.pgpSign(SettingsUserStore.pgpSign()); - this.pgpEncrypt(SettingsUserStore.pgpEncrypt()); + this.doSign(SettingsUserStore.pgpSign()); + this.doEncrypt(SettingsUserStore.pgpEncrypt()); this.attachments([]); @@ -1449,8 +1458,8 @@ export class ComposePopupView extends AbstractViewPopup { linkedData: [] }, recipients = draft ? [identity.email()] : this.allRecipients(), - sign = !draft && this.pgpSign() && this.canPgpSign(), - encrypt = this.pgpEncrypt() && this.canPgpEncrypt(), + sign = !draft && this.doSign() && (this.canPgpSign() || this.canSMimeSign()), + encrypt = this.doEncrypt() && (this.canPgpEncrypt() || this.canSMimeEncrypt()), isHtml = this.oEditor.isHtml(); if (isHtml) { @@ -1497,30 +1506,37 @@ export class ComposePopupView extends AbstractViewPopup { alternative.children.push(data); data = alternative; } - if (!draft && sign?.[1]) { - if ('openpgp' == sign[0]) { - // Doesn't sign attachments - params.html = params.plain = ''; - let signed = new MimePart; - signed.headers['Content-Type'] = - 'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"'; - signed.headers['Content-Transfer-Encoding'] = '7Bit'; - signed.children.push(data); - let signature = new MimePart; - signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"'; - signature.headers['Content-Transfer-Encoding'] = '7Bit'; - signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1); - signed.children.push(signature); - params.signed = signed.toString(); - params.boundary = signed.boundary; - data = signed; - } else if ('gnupg' == sign[0]) { - // TODO: sign in PHP fails -// params.signData = data.toString(); - params.signFingerprint = sign[1].fingerprint; - params.signPassphrase = await GnuPGUserStore.sign(sign[1]); - } else { - throw 'Signing with ' + sign[0] + ' not yet implemented'; + if (sign) { + if (sign?.[1]) { + if ('openpgp' == sign[0]) { + // Doesn't sign attachments + params.html = params.plain = ''; + let signed = new MimePart; + signed.headers['Content-Type'] = + 'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"'; + signed.headers['Content-Transfer-Encoding'] = '7Bit'; + signed.children.push(data); + let signature = new MimePart; + signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"'; + signature.headers['Content-Transfer-Encoding'] = '7Bit'; + signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1); + signed.children.push(signature); + params.signed = signed.toString(); + params.boundary = signed.boundary; + data = signed; + } else if ('gnupg' == sign[0]) { + // TODO: sign in PHP fails + // params.signData = data.toString(); + params.signFingerprint = sign[1].fingerprint; + params.signPassphrase = await GnuPGUserStore.sign(sign[1]); + } else { + throw 'Signing with ' + sign[0] + ' not yet implemented'; + } + } else if (this.canSMimeSign()) { + params.signCertificate = identity.smimeCertificate(); + params.signPrivateKey = identity.smimeKey(); + const pass = await AskPopupView.password('S/MIME key', 'OPENPGP/LABEL_SIGN'); + params.signPassphrase = pass?.password; } } if (encrypt) { @@ -1534,6 +1550,9 @@ export class ComposePopupView extends AbstractViewPopup { } else if ('gnupg' == encrypt) { // Does encrypt attachments params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients)); +// } else { +// // S/MIME +// params.encryptCertificates = ''; } else { throw 'Encryption with ' + encrypt + ' not yet implemented'; } diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Imap/BodyStructure.php b/snappymail/v/0.0.0/app/libraries/MailSo/Imap/BodyStructure.php index dd912746b..1370900bf 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Imap/BodyStructure.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Imap/BodyStructure.php @@ -11,6 +11,8 @@ namespace MailSo\Imap; +use MailSo\Mime\Enumerations\ContentType; + /** * @category MailSo * @package Imap @@ -165,10 +167,10 @@ class BodyStructure implements \JsonSerializable { return ('multipart/signed' === $this->sContentType && !empty($this->aContentTypeParams['protocol']) - && 'application/pkcs7-signature' === \strtolower(\trim($this->aContentTypeParams['protocol'])) + && ContentType::isPkcs7Signature(\strtolower(\trim($this->aContentTypeParams['protocol']))) // The multipart/signed body MUST consist of exactly two parts. && 2 === \count($this->aSubParts) - && 'application/pkcs7-signature' === $this->aSubParts[1]->ContentType() + && ContentType::isPkcs7Signature($this->aSubParts[1]->ContentType()) ) || ('application/pkcs7-mime' === $this->sContentType && !empty($this->aContentTypeParams['smime-type']) && 'signed-data' === \strtolower(\trim($this->aContentTypeParams['smime-type'])) diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Enumerations/ContentType.php b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Enumerations/ContentType.php index b2e4b60d6..bb0fb7320 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Enumerations/ContentType.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Enumerations/ContentType.php @@ -24,4 +24,10 @@ abstract class ContentType const PGP_SIGNATURE = 'application/pgp-signature'; const PKCS7_SIGNATURE = 'application/pkcs7-signature'; const PKCS7_MIME = 'application/pkcs7-mime'; + + public static function isPkcs7Signature(string $data) : bool + { + return 'application/pkcs7-signature' === $data + || 'application/x-pkcs7-signature' === $data; + } } diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Part.php b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Part.php index 7273f76c7..54d737e19 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Part.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Part.php @@ -11,6 +11,8 @@ namespace MailSo\Mime; +use MailSo\Mime\Enumerations\ContentType; + /** * @category MailSo * @package Mime @@ -96,10 +98,10 @@ class Part { $header = $this->Headers->GetByName(Enumerations\Header::CONTENT_TYPE); return ($header - && \preg_match('#multipart/signed.+protocol=["\']?application/pkcs7-signature#si', $header->FullValue()) + && \preg_match('#multipart/signed.+protocol=["\']?application/(x-)?pkcs7-signature#si', $header->FullValue()) // The multipart/signed body MUST consist of exactly two parts. && 2 === \count($this->SubParts) - && 'application/pkcs7-signature' === $this->SubParts[1]->ContentType() + && ContentType::isPkcs7Signature($this->SubParts[1]->ContentType()) ) || ($header && \preg_match('#application/pkcs7-mime.+smime-type=["\']?signed-data#si', $header->FullValue()) ); diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php index 548346671..9e8541077 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php @@ -1231,7 +1231,7 @@ trait Messages $resource = $oBody->ToStream(); \MailSo\Base\StreamFilters\LineEndings::appendTo($resource); - $tmp = new \SnappyMail\File\Temporary; + $tmp = new \SnappyMail\File\Temporary('mimepart'); $tmp->writeFromStream($resource); $oBody->Body = null; @@ -1244,7 +1244,7 @@ trait Messages $sSignature = $SMIME->sign($tmp, $sCertificate); if (!$sSignature) { - throw new \Exception('GnuPG sign() failed'); + throw new \Exception('S/MIME sign() failed'); } $oPart = new MimePart; @@ -1261,7 +1261,7 @@ trait Messages $oSignaturePart = new MimePart; $oSignaturePart->Headers->AddByName(MimeEnumHeader::CONTENT_TYPE, 'application/pkcs7-signature; name="signature.p7s"'); - $oSignaturePart->Headers->AddByName(MimeEnumHeader::CONTENT_TRANSFER_ENCODING, '7Bit'); + $oSignaturePart->Headers->AddByName(MimeEnumHeader::CONTENT_TRANSFER_ENCODING, 'base64'); $oSignaturePart->Body = $sSignature; $oPart->SubParts->append($oSignaturePart); } @@ -1285,7 +1285,7 @@ trait Messages } else { $aCertificates = \json_decode($this->GetActionParam('encryptCertificates', ''), true); if ($aCertificates) { - $tmp = new \SnappyMail\File\Temporary; + $tmp = new \SnappyMail\File\Temporary('mimepart'); $tmp->writeFromStream($oMessage->GetRootPart()->ToStream()); $oMessage->SubParts->Clear(); diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/file/temporary.php b/snappymail/v/0.0.0/app/libraries/snappymail/file/temporary.php index 9b6d37fa4..90ef61104 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/file/temporary.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/file/temporary.php @@ -4,7 +4,7 @@ namespace SnappyMail\File; class Temporary { - protected static string $filename = ''; + protected string $filename = ''; function __construct(string $name, bool $prefix = true) { @@ -15,7 +15,7 @@ class Temporary throw new \Exception("Failed to create directory {$tmpdir}"); } if (!\is_writable($tmpdir)) { - throw new \Exception("Failed to access directory {$tmpdir}"); +// throw new \Exception("Failed to access directory {$tmpdir}"); } if ($prefix) { $this->filename = \tempnam($tmpdir, $name); @@ -42,10 +42,10 @@ class Temporary private $fp = null; public function fopen()/* : resource|false*/ { - if (!$fp) { - $fp = \fopen($this->filename, 'r+b'); + if (!$this->fp) { + $this->fp = \fopen($this->filename, 'r+b'); } - return $fp; + return $this->fp; } public function writeFromStream(/*resource*/ $from)/* : int|false*/ diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/smime/certificate.php b/snappymail/v/0.0.0/app/libraries/snappymail/smime/certificate.php index e848b3f62..d3c464476 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/smime/certificate.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/smime/certificate.php @@ -6,6 +6,8 @@ class Certificate { public $x509 = null, + $pkey = null, + $digest = 'sha256', $cipher = \OPENSSL_CIPHER_AES_256_CBC, $keyBits = 4096, @@ -32,10 +34,17 @@ class Certificate * A string having the format file://path/to/cert.pem; the named file must contain a PEM encoded certificate * A string containing the content of a certificate, PEM encoded, may start with -----BEGIN CERTIFICATE----- */ - function __construct($x509cert = null) + function __construct($x509cert = null, $privateKey = null) { if ($x509cert) { - $this->x509 = \openssl_x509_read($x509cert); + $x509cert = \openssl_x509_read($x509cert); + if (!$x509cert) { + throw new \RuntimeException('OpenSSL x509: ' . \openssl_error_string()); + } + $this->x509 = $x509cert; + if ($privateKey && \openssl_x509_check_private_key($this->x509, $privateKey)) { + $this->pkey = $privateKey; + } } } diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php b/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php index 71910e425..f8770614b 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php @@ -24,6 +24,9 @@ class OpenSSL ) : void { $this->private_key = \openssl_pkey_get_private($private_key, $passphrase); + if (!$this->private_key) { + throw new \RuntimeException('OpenSSL setPrivateKey: ' . \openssl_error_string()); + } } public function decrypt(string $data, $certificate = null, $private_key = null) : ?string @@ -67,31 +70,35 @@ class OpenSSL } } $output = new Temporary('smimeout-'); - if (!$input->putContents($data) || !\openssl_pkcs7_sign( + if (!\openssl_pkcs7_sign( $input->filename(), $output->filename(), $certificate ?: $this->certificate, // \openssl_pkey_get_public(); $private_key ?: $this->private_key, // \openssl_pkey_get_private($private_key, ?string $passphrase = null); $this->headers, - \PKCS7_DETACHED, // | PKCS7_NOCERTS | PKCS7_NOATTR + \PKCS7_DETACHED | \PKCS7_BINARY, // | PKCS7_NOCERTS | PKCS7_NOATTR $this->untrusted_certificates_filename )) { - return null; + throw new \RuntimeException('OpenSSL sign: ' . \openssl_error_string()); } -/* + $body = $output->getContents(); - // The message returned by openssl contains both headers and body, so need to split them up - $parts = explode("\n\n", $body, 2); - $this->MIMEHeader .= $parts[0] . static::$LE . static::$LE; - $body = $parts[1]; -*/ - return $output->getContents(); + if (\preg_match('/\\.p7s"\R\R(.+?)------/s', $body, $match)) { + return \trim($match[1]); + } + + throw new \RuntimeException('OpenSSL sign: failed to find p7s'); } public function verify(string $data, $signers_certificates_filename = null) { - $input = new Temporary('smimein-'); - return $input->putContents($data) && true === \openssl_pkcs7_verify( + if (\is_string($input)) { + $input = new Temporary('smimein-'); + if (!$input->putContents($data)) { + return null; + } + } + return true === \openssl_pkcs7_verify( $input->filename(), $flags = 0, $signers_certificates_filename ?: null, diff --git a/snappymail/v/0.0.0/app/templates/Views/User/PopupsCompose.html b/snappymail/v/0.0.0/app/templates/Views/User/PopupsCompose.html index 4e943bb12..7b541855d 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/PopupsCompose.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/PopupsCompose.html @@ -60,15 +60,15 @@ -