mirror of https://github.com/MISP/MISP
new: [internal] Support autocrypt when sending e-mails
parent
ee4de160e8
commit
46c5b824cb
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
class CryptGpgExtended extends Crypt_GPG
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Export the smallest public key possible from the keyring.
|
||||||
|
*
|
||||||
|
* This removes all signatures except the most recent self-signature on each user ID. This option is the same as
|
||||||
|
* running the --edit-key command "minimize" before export except that the local copy of the key is not modified.
|
||||||
|
*
|
||||||
|
* The exported key remains on the keyring. To delete the public key, use
|
||||||
|
* {@link Crypt_GPG::deletePublicKey()}.
|
||||||
|
*
|
||||||
|
* If more than one key fingerprint is available for the specified
|
||||||
|
* <kbd>$keyId</kbd> (for example, if you use a non-unique uid) only the
|
||||||
|
* first public key is exported.
|
||||||
|
*
|
||||||
|
* @param string $keyId either the full uid of the public key, the email
|
||||||
|
* part of the uid of the public key or the key id of
|
||||||
|
* the public key. For example,
|
||||||
|
* "Test User (example) <test@example.com>",
|
||||||
|
* "test@example.com" or a hexadecimal string.
|
||||||
|
* @param boolean $armor optional. If true, ASCII armored data is returned;
|
||||||
|
* otherwise, binary data is returned. Defaults to
|
||||||
|
* true.
|
||||||
|
*
|
||||||
|
* @return string the public key data.
|
||||||
|
*
|
||||||
|
* @throws Crypt_GPG_KeyNotFoundException if a public key with the given
|
||||||
|
* <kbd>$keyId</kbd> is not found.
|
||||||
|
*
|
||||||
|
* @throws Crypt_GPG_Exception if an unknown or unexpected error occurs.
|
||||||
|
* Use the <kbd>debug</kbd> option and file a bug report if these
|
||||||
|
* exceptions occur.
|
||||||
|
*/
|
||||||
|
public function exportPublicKeyMinimal($keyId, $armor = true)
|
||||||
|
{
|
||||||
|
$fingerprint = $this->getFingerprint($keyId);
|
||||||
|
|
||||||
|
if ($fingerprint === null) {
|
||||||
|
throw new Crypt_GPG_KeyNotFoundException(
|
||||||
|
'Key not found: ' . $keyId,
|
||||||
|
self::ERROR_KEY_NOT_FOUND,
|
||||||
|
$keyId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyData = '';
|
||||||
|
$operation = '--export';
|
||||||
|
$operation .= ' ' . escapeshellarg($fingerprint);
|
||||||
|
|
||||||
|
$arguments = array('--export-options', 'export-minimal');
|
||||||
|
if ($armor) {
|
||||||
|
$arguments[] = '--armor';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->engine->reset();
|
||||||
|
$this->engine->setPins($this->passphrases);
|
||||||
|
$this->engine->setOutput($keyData);
|
||||||
|
$this->engine->setOperation($operation, $arguments);
|
||||||
|
$this->engine->run();
|
||||||
|
|
||||||
|
return $keyData;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
class GpgTool
|
class GpgTool
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return Crypt_GPG
|
* @return CryptGpgExtended
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function initializeGpg()
|
public function initializeGpg()
|
||||||
|
@ -15,6 +15,8 @@ class GpgTool
|
||||||
require_once 'Crypt/GPG.php';
|
require_once 'Crypt/GPG.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/CryptGpgExtended.php';
|
||||||
|
|
||||||
$homedir = Configure::read('GnuPG.homedir');
|
$homedir = Configure::read('GnuPG.homedir');
|
||||||
if ($homedir === null) {
|
if ($homedir === null) {
|
||||||
throw new Exception("Configuration option 'GnuPG.homedir' is not set, Crypt_GPG cannot be initialized.");
|
throw new Exception("Configuration option 'GnuPG.homedir' is not set, Crypt_GPG cannot be initialized.");
|
||||||
|
@ -26,7 +28,7 @@ class GpgTool
|
||||||
'binary' => Configure::read('GnuPG.binary') ?: '/usr/bin/gpg',
|
'binary' => Configure::read('GnuPG.binary') ?: '/usr/bin/gpg',
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Crypt_GPG($options);
|
return new CryptGpgExtended($options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,13 +3,25 @@ App::uses('CakeEmail', 'Network/Email');
|
||||||
|
|
||||||
class SendEmailException extends Exception {}
|
class SendEmailException extends Exception {}
|
||||||
|
|
||||||
// RFC 4880 and 3156
|
/**
|
||||||
// https://dkg.fifthhorseman.net/notes/inline-pgp-harmful/
|
* Class CakeEmailExtended
|
||||||
// https://www.dalesandro.net/create-self-signed-smime-certificates/
|
*
|
||||||
|
* Extends `CakeEmail` to implement RFC 4880 and 3156.
|
||||||
|
*
|
||||||
|
* @see https://dkg.fifthhorseman.net/notes/inline-pgp-harmful/
|
||||||
|
* @see https://www.dalesandro.net/create-self-signed-smime-certificates/
|
||||||
|
*/
|
||||||
class CakeEmailExtended extends CakeEmail
|
class CakeEmailExtended extends CakeEmail
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var MimeMultipart|MessagePart
|
||||||
|
*/
|
||||||
private $body;
|
private $body;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $include
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
public function getHeaders($include = array())
|
public function getHeaders($include = array())
|
||||||
{
|
{
|
||||||
$headers = parent::getHeaders($include);
|
$headers = parent::getHeaders($include);
|
||||||
|
@ -25,6 +37,9 @@ class CakeEmailExtended extends CakeEmail
|
||||||
return $headers;
|
return $headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
public function boundary()
|
public function boundary()
|
||||||
{
|
{
|
||||||
if ($this->body instanceof MimeMultipart) {
|
if ($this->body instanceof MimeMultipart) {
|
||||||
|
@ -36,7 +51,7 @@ class CakeEmailExtended extends CakeEmail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string|null|MimeMultipart|MessagePart $message
|
* @param string|null|MimeMultipart|MessagePart $message
|
||||||
* @return $this
|
* @return string|null|MimeMultipart|MessagePart|CakeEmailExtended
|
||||||
*/
|
*/
|
||||||
public function body($message = null)
|
public function body($message = null)
|
||||||
{
|
{
|
||||||
|
@ -239,7 +254,7 @@ class MessagePart
|
||||||
class SendEmail
|
class SendEmail
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var Crypt_GPG
|
* @var CryptGpgExtended
|
||||||
*/
|
*/
|
||||||
private $gpg;
|
private $gpg;
|
||||||
|
|
||||||
|
@ -340,7 +355,7 @@ class SendEmail
|
||||||
throw new SendEmailException('Emailing is currently disabled on this instance.');
|
throw new SendEmailException('Emailing is currently disabled on this instance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the e-mail can be encrypted
|
// Check if the e-mail can be encrypted
|
||||||
$canEncryptGpg = isset($user['User']['gpgkey']) && !empty($user['User']['gpgkey']);
|
$canEncryptGpg = isset($user['User']['gpgkey']) && !empty($user['User']['gpgkey']);
|
||||||
$canEncryptSmime = isset($user['User']['certif_public']) && !empty($user['User']['certif_public']) && Configure::read('SMIME.enabled');
|
$canEncryptSmime = isset($user['User']['certif_public']) && !empty($user['User']['certif_public']) && Configure::read('SMIME.enabled');
|
||||||
|
|
||||||
|
@ -348,7 +363,7 @@ class SendEmail
|
||||||
throw new SendEmailException('Encrypted messages are enforced and the message could not be encrypted for this user as no valid encryption key was found.');
|
throw new SendEmailException('Encrypted messages are enforced and the message could not be encrypted for this user as no valid encryption key was found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If bodyonlyencrypted is enabled and the user has no encryption key, use the alternate body (if it exists)
|
// If 'bodyonlyencrypted' is enabled and the user has no encryption key, use the alternate body (if it exists)
|
||||||
if (Configure::read('GnuPG.bodyonlyencrypted') && !$canEncryptSmime && !$canEncryptGpg && $bodyWithoutEncryption) {
|
if (Configure::read('GnuPG.bodyonlyencrypted') && !$canEncryptSmime && !$canEncryptGpg && $bodyWithoutEncryption) {
|
||||||
$body = $bodyWithoutEncryption;
|
$body = $bodyWithoutEncryption;
|
||||||
}
|
}
|
||||||
|
@ -365,7 +380,10 @@ class SendEmail
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->gpg->addSignKey(Configure::read('GnuPG.email'), Configure::read('GnuPG.password'));
|
$this->gpg->addSignKey(Configure::read('GnuPG.email'), Configure::read('GnuPG.password'));
|
||||||
$this->signByGpg($email);
|
$this->signByGpg($email, $replyToUser);
|
||||||
|
$this->gpg->clearSignKeys();
|
||||||
|
|
||||||
|
$email->addHeaders(array('Autocrypt' => $this->generateAutocrypt(Configure::read('GnuPG.email'))));
|
||||||
$signed = true;
|
$signed = true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
throw new SendEmailException("The message could not be signed.", 0, $e);
|
throw new SendEmailException("The message could not be signed.", 0, $e);
|
||||||
|
@ -391,6 +409,7 @@ class SendEmail
|
||||||
try {
|
try {
|
||||||
$this->gpg->addEncryptKey($fingerprint);
|
$this->gpg->addEncryptKey($fingerprint);
|
||||||
$this->encryptByGpg($email);
|
$this->encryptByGpg($email);
|
||||||
|
$this->gpg->clearEncryptKeys();
|
||||||
|
|
||||||
if ($signed && Configure::read('GnuPG.obscure_subject')) {
|
if ($signed && Configure::read('GnuPG.obscure_subject')) {
|
||||||
// If message is signed, we can remove subject from unencrypted part of email and replace with '...',
|
// If message is signed, we can remove subject from unencrypted part of email and replace with '...',
|
||||||
|
@ -402,7 +421,7 @@ class SendEmail
|
||||||
|
|
||||||
$encrypted = true;
|
$encrypted = true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
throw new SendEmailException("The message could not be encrypted.", 0, $e);
|
throw new SendEmailException('The message could not be encrypted.', 0, $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -416,7 +435,7 @@ class SendEmail
|
||||||
$email->send();
|
$email->send();
|
||||||
return $encrypted;
|
return $encrypted;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
throw new SendEmailException("The message could not be sent.", 0, $e);
|
throw new SendEmailException('The message could not be sent.', 0, $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -431,20 +450,24 @@ class SendEmail
|
||||||
// Try to encrypt empty message
|
// Try to encrypt empty message
|
||||||
$this->encryptTextBySmime($certificate, '');
|
$this->encryptTextBySmime($certificate, '');
|
||||||
} catch (SendEmailException $e) {
|
} catch (SendEmailException $e) {
|
||||||
throw new Exception("This certificate cannot be used to encrypt email", 0, $e);
|
throw new Exception('This certificate cannot be used to encrypt email.', 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
$parsed = openssl_x509_parse($certificate);
|
$parsed = openssl_x509_parse($certificate);
|
||||||
|
|
||||||
// 5 should be 'smimeencrypt'
|
if (!$parsed) {
|
||||||
if (!($parsed['purposes'][5][0] === 1 && $parsed['purposes'][5][2] === 'smimeencrypt')) {
|
throw new Exception('Could not parse certificate');
|
||||||
throw new Exception('This certificate cannot be used to encrypt email');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = new DateTime("now");
|
// Purpose '5' should be 'smimeencrypt'
|
||||||
|
if (!($parsed['purposes'][5][0] === 1 && $parsed['purposes'][5][2] === 'smimeencrypt')) {
|
||||||
|
throw new Exception('This certificate cannot be used to encrypt email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = new DateTime();
|
||||||
$validToTime = new DateTime("@{$parsed['validTo_time_t']}");
|
$validToTime = new DateTime("@{$parsed['validTo_time_t']}");
|
||||||
if ($validToTime <= $now) {
|
if ($validToTime <= $now) {
|
||||||
throw new Exception('This certificate is expired');
|
throw new Exception('This certificate is expired.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -477,10 +500,12 @@ class SendEmail
|
||||||
} elseif (!empty($replyToUser['User']['certif_public'])) {
|
} elseif (!empty($replyToUser['User']['certif_public'])) {
|
||||||
$attachments[$replyToUser['User']['email'] . '.pem'] = $replyToUser['User']['certif_public'];
|
$attachments[$replyToUser['User']['email'] . '.pem'] = $replyToUser['User']['certif_public'];
|
||||||
}
|
}
|
||||||
|
} else if (Configure::read('MISP.email_reply_to')) {
|
||||||
|
$email->replyTo(Configure::read('MISP.email_reply_to'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$email->from(Configure::read('MISP.email'));
|
$email->from(Configure::read('MISP.email'));
|
||||||
$email->returnPath(Configure::read('MISP.email'));
|
$email->returnPath(Configure::read('MISP.email')); // TODO?
|
||||||
$email->to($user['User']['email']);
|
$email->to($user['User']['email']);
|
||||||
$email->subject($subject);
|
$email->subject($subject);
|
||||||
$email->emailFormat('text');
|
$email->emailFormat('text');
|
||||||
|
@ -496,8 +521,9 @@ class SendEmail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param CakeEmailExtended $email
|
* @param CakeEmailExtended $email
|
||||||
|
* @param array $replyToUser
|
||||||
*/
|
*/
|
||||||
private function signByGpg(CakeEmailExtended $email)
|
private function signByGpg(CakeEmailExtended $email, array $replyToUser = array())
|
||||||
{
|
{
|
||||||
$renderedEmail = $email->render();
|
$renderedEmail = $email->render();
|
||||||
|
|
||||||
|
@ -507,6 +533,7 @@ class SendEmail
|
||||||
'boundary="' . $email->boundary() . '"',
|
'boundary="' . $email->boundary() . '"',
|
||||||
'protected-headers="v1"',
|
'protected-headers="v1"',
|
||||||
));
|
));
|
||||||
|
|
||||||
// Protect User-Facing Headers according to https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-01.html
|
// Protect User-Facing Headers according to https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-01.html
|
||||||
$originalHeaders = $email->getHeaders(array('subject', 'from', 'to'));
|
$originalHeaders = $email->getHeaders(array('subject', 'from', 'to'));
|
||||||
$protectedHeaders = array('From', 'To', 'Date', 'Message-ID', 'Subject', 'Reply-To');
|
$protectedHeaders = array('From', 'To', 'Date', 'Message-ID', 'Subject', 'Reply-To');
|
||||||
|
@ -515,6 +542,21 @@ class SendEmail
|
||||||
$messagePart->addHeader($header, $originalHeaders[$header]);
|
$messagePart->addHeader($header, $originalHeaders[$header]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the e-mail is sent on behalf of a user and that user has assigned GPG key, we will send his public key
|
||||||
|
// in signed autocrypt header.
|
||||||
|
if ($replyToUser) {
|
||||||
|
if (!empty($replyToUser['User']['gpgkey'])) {
|
||||||
|
$autocrypt = $this->generateAutocrypt($replyToUser['User']['email'], $replyToUser['User']['gpgkey'], false);
|
||||||
|
$messagePart->addHeader('Autocrypt-Gossip', $autocrypt);
|
||||||
|
}
|
||||||
|
} else if (Configure::read('MISP.email_reply_to')) {
|
||||||
|
$autocrypt = $this->generateAutocrypt(Configure::read('MISP.email_reply_to'), null, false);
|
||||||
|
if ($autocrypt) {
|
||||||
|
$messagePart->addHeader('Autocrypt-Gossip', $autocrypt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$messagePart->setPayload($renderedEmail);
|
$messagePart->setPayload($renderedEmail);
|
||||||
|
|
||||||
// GPG message to sign must be delimited by <CR><LF>
|
// GPG message to sign must be delimited by <CR><LF>
|
||||||
|
@ -760,4 +802,37 @@ class SendEmail
|
||||||
$second = base_convert(mt_rand(), 10, 36) . base_convert(mt_rand(), 10, 36);
|
$second = base_convert(mt_rand(), 10, 36) . base_convert(mt_rand(), 10, 36);
|
||||||
return "<$first.$second@{$email->domain()}>";
|
return "<$first.$second@{$email->domain()}>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates Autocrypt header.
|
||||||
|
*
|
||||||
|
* If $gpgKey is not provided, GPG will try to find correct key by given e-mail address. If no key found, `null` is
|
||||||
|
* returned.
|
||||||
|
*
|
||||||
|
* @see https://autocrypt.org/level1.html
|
||||||
|
* @param string $address
|
||||||
|
* @param string|null $gpgKey
|
||||||
|
* @param bool $preferEncrypt
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function generateAutocrypt($address, $gpgKey = null, $preferEncrypt = true)
|
||||||
|
{
|
||||||
|
if ($gpgKey) {
|
||||||
|
$keyImportOutput = $this->gpg->importKey($gpgKey);
|
||||||
|
$keyData = $this->gpg->exportPublicKeyMinimal($keyImportOutput['fingerprint'], false);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$keyData = $this->gpg->exportPublicKeyMinimal($address, false);
|
||||||
|
} catch (Crypt_GPG_KeyNotFoundException $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array("addr=$address");
|
||||||
|
if ($preferEncrypt) {
|
||||||
|
$parts[] = 'prefer-encrypt=mutual';
|
||||||
|
}
|
||||||
|
$parts[] = 'keydata=' . base64_encode($keyData);
|
||||||
|
return implode('; ', $parts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue