From 68d103b3c19938fce76fc3c58ae12ec2d76a4697 Mon Sep 17 00:00:00 2001 From: djmaze Date: Mon, 29 Mar 2021 20:51:14 +0200 Subject: [PATCH] Added support for SCRAM-SHA-1 & SCRAM-SHA-256 https://github.com/RainLoop/rainloop-webmail/issues/1914 --- .../app/libraries/MailSo/Smtp/SmtpClient.php | 200 +++++++----------- .../v/0.0.0/app/libraries/snappymail/sasl.php | 52 +++++ .../app/libraries/snappymail/sasl/cram.php | 27 +++ .../app/libraries/snappymail/sasl/login.php | 32 +++ .../app/libraries/snappymail/sasl/plain.php | 18 ++ .../app/libraries/snappymail/sasl/scram.php | 119 +++++++++++ 6 files changed, 330 insertions(+), 118 deletions(-) create mode 100644 snappymail/v/0.0.0/app/libraries/snappymail/sasl.php create mode 100644 snappymail/v/0.0.0/app/libraries/snappymail/sasl/cram.php create mode 100644 snappymail/v/0.0.0/app/libraries/snappymail/sasl/login.php create mode 100644 snappymail/v/0.0.0/app/libraries/snappymail/sasl/plain.php create mode 100644 snappymail/v/0.0.0/app/libraries/snappymail/sasl/scram.php diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Smtp/SmtpClient.php b/snappymail/v/0.0.0/app/libraries/MailSo/Smtp/SmtpClient.php index 96974594b..b9acd0787 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Smtp/SmtpClient.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Smtp/SmtpClient.php @@ -62,11 +62,6 @@ class SmtpClient extends \MailSo\Net\NetClient */ private $aResults; - /** - * @var bool - */ - public $__USE_SINGLE_LINE_AUTH_PLAIN_COMMAND = false; - function __construct() { parent::__construct(); @@ -150,126 +145,94 @@ class SmtpClient extends \MailSo\Net\NetClient { $sLogin = \MailSo\Base\Utils::IdnToAscii(\MailSo\Base\Utils::Trim($sLogin)); - if ($bUseAuthCramMd5IfSupported && $this->IsAuthSupported('CRAM-MD5')) - { - try - { - $this->sendRequestWithCheck('AUTH', 334, 'CRAM-MD5'); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadMethodException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - - $sTicket = ''; - - $sContinuationResponse = !empty($this->aResults[0]) ? \trim($this->aResults[0]) : ''; - if ($sContinuationResponse && '334 ' === \substr($sContinuationResponse, 0, 4) && 0 < \strlen(\substr($sContinuationResponse, 4))) - { - $sTicket = \base64_decode(\substr($sContinuationResponse, 4)); - $this->writeLogWithCrlf('ticket: '.$sTicket); - } - - if (empty($sTicket)) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\NegativeResponseException, - \MailSo\Log\Enumerations\Type::NOTICE, true - ); - } - - try - { - $this->sendRequestWithCheck(\base64_encode($sLogin.' '.\hash_hmac('md5', $sTicket, $sPassword)), 235, '', true); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadCredentialsException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); +// $encrypted = !empty(\stream_get_meta_data($this->rConnect)['crypto']); + $type = ''; + $types = [ + 'SCRAM-SHA-256' => 1, // !$encrypted + 'SCRAM-SHA-1' => 1, // !$encrypted + 'CRAM-MD5' => $bUseAuthCramMd5IfSupported, + 'PLAIN' => $bUseAuthPlainIfSupported, + 'LOGIN' => 1 // $encrypted + ]; + foreach ($types as $sasl_type => $active) { + if ($active && $this->IsAuthSupported($sasl_type) && \SnappyMail\SASL::isSupported($sasl_type)) { + $type = $sasl_type; + break; } } - else if ($bUseAuthPlainIfSupported && $this->IsAuthSupported('PLAIN')) - { - if ($this->__USE_SINGLE_LINE_AUTH_PLAIN_COMMAND) - { - try - { - $this->sendRequestWithCheck('AUTH', 235, 'PLAIN '.\base64_encode("\0".$sLogin."\0".$sPassword), true); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadCredentialsException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - } - else - { - try - { - $this->sendRequestWithCheck('AUTH', 334, 'PLAIN'); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadMethodException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - try - { - $this->sendRequestWithCheck(\base64_encode("\0".$sLogin."\0".$sPassword), 235, '', true); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadCredentialsException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - } - } - else if ($this->IsAuthSupported('LOGIN')) - { - try - { - $this->sendRequestWithCheck('AUTH', 334, 'LOGIN'); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadMethodException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - - try - { - $this->sendRequestWithCheck(\base64_encode($sLogin), 334, ''); - $this->sendRequestWithCheck(\base64_encode($sPassword), 235, '', true); - } - catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) - { - $this->writeLogException( - new \MailSo\Smtp\Exceptions\LoginBadCredentialsException( - $oException->GetResponses(), $oException->getMessage(), 0, $oException), - \MailSo\Log\Enumerations\Type::NOTICE, true); - } - } - else - { + if (!$type) { $this->writeLogException( new \MailSo\Smtp\Exceptions\LoginBadMethodException, \MailSo\Log\Enumerations\Type::NOTICE, true); } + $SASL = \SnappyMail\SASL::factory($type); + $SASL->base64 = true; + + // Start authentication + try + { + $sResult = $this->sendRequestWithCheck('AUTH', 334, $type); + } + catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) + { + $this->writeLogException( + new \MailSo\Smtp\Exceptions\LoginBadMethodException( + $oException->GetResponses(), $oException->getMessage(), 0, $oException), + \MailSo\Log\Enumerations\Type::NOTICE, true); + } + + try + { + switch ($type) + { + // RFC 4616 + case 'PLAIN': + $this->sendRequestWithCheck($SASL->authenticate($username, $passphrase), 235, '', true); + break; + + case 'LOGIN': + $sResult = $this->sendRequestWithCheck($SASL->authenticate($username, $passphrase, $sResult), 334, ''); + $this->sendRequestWithCheck($SASL->challenge($sResult), 235, '', true); + break; + + // RFC 2195 + case 'CRAM-MD5': + if (empty($sResult)) { + $this->writeLogException( + new \MailSo\Smtp\Exceptions\NegativeResponseException, + \MailSo\Log\Enumerations\Type::NOTICE, true + ); + } + $this->sendRequestWithCheck($SASL->authenticate($sLogin, $sPassword, $sResult), 235, '', true); + break; + + // RFC 5802 + case 'SCRAM-SHA-1': + case 'SCRAM-SHA-256': + $sResult = $this->sendRequestWithCheck($SASL->authenticate($username, $passphrase, $sResult), 234, ''); + $sResult = $this->sendRequestWithCheck($SASL->challenge($sResult), 235, '', true); + $SASL->verify($sResult); + break; + +/* + // https://developers.google.com/gmail/imap/xoauth2-protocol + case 'XOAUTH2': + throw new \Exception('Please use app passphrases: https://support.google.com/mail/answer/185833'); + break; +*/ + + } + } + catch (\MailSo\Smtp\Exceptions\NegativeResponseException $oException) + { + $this->writeLogException( + new \MailSo\Smtp\Exceptions\LoginBadCredentialsException( + $oException->GetResponses(), $oException->getMessage(), 0, $oException), + \MailSo\Log\Enumerations\Type::NOTICE, true); + } + return $this; } @@ -542,10 +505,11 @@ class SmtpClient extends \MailSo\Net\NetClient * @throws \MailSo\Net\Exceptions\Exception * @throws \MailSo\Smtp\Exceptions\Exception */ - private function sendRequestWithCheck(string $sCommand, $mExpectCode, string $sAddToCommand = '', bool $bSecureLog = false, string $sErrorPrefix = '') : void + private function sendRequestWithCheck(string $sCommand, $mExpectCode, string $sAddToCommand = '', bool $bSecureLog = false, string $sErrorPrefix = '') : string { $this->sendRequest($sCommand, $sAddToCommand, $bSecureLog); $this->validateResponse($mExpectCode, $sErrorPrefix); + return empty($this->aResults[0]) ? '' : \substr($this->aResults[0], 4); } private function ehloOrHelo(string $sHost) : void diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/sasl.php b/snappymail/v/0.0.0/app/libraries/snappymail/sasl.php new file mode 100644 index 000000000..ef7565eab --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/sasl.php @@ -0,0 +1,52 @@ +base64 ? \base64_decode($data) : $data; + } + + final protected function encode(string $data) : string + { + return $this->base64 ? \base64_encode($data) : $data; + } + +} diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/sasl/cram.php b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/cram.php new file mode 100644 index 000000000..4ed7f6b7e --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/cram.php @@ -0,0 +1,27 @@ +algo = $algo; + } + + public function authenticate(string $authcid, string $passphrase, ?string $challenge = null) : string + { + return $this->encode($authcid . ' ' . \hash_hmac($this->algo, $this->decode($challenge), $passphrase)); + } + + public static function isSupported(string $param) : bool + { + return \in_array(\strtolower($param), \hash_algos()); + } + +} diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/sasl/login.php b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/login.php new file mode 100644 index 000000000..743a62f6f --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/login.php @@ -0,0 +1,32 @@ +decode($challenge)) { + throw new \Exception("Invalid response: {$challenge}"); + } + $this->passphrase = $passphrase; + return $this->encode($username); + } + + public function challenge(string $challenge) : string + { + if ($challenge && 'Password:' !== $this->decode($challenge)) { + throw new \Exception("invalid response: {$challenge}"); + } + return $this->encode($this->passphrase); + } + + public static function isSupported(string $param) : bool + { + return true; + } + +} diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/sasl/plain.php b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/plain.php new file mode 100644 index 000000000..dca6aa103 --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/plain.php @@ -0,0 +1,18 @@ +encode("{$authzid}\x00{$username}\x00{$passphrase}"); + } + + public static function isSupported(string $param) : bool + { + return true; + } + +} diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/sasl/scram.php b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/scram.php new file mode 100644 index 000000000..f1b630917 --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/sasl/scram.php @@ -0,0 +1,119 @@ +algo = $algo; + } + + public function authenticate(string $authcid, string $passphrase, ?string $authzid = null) : string + { + // SASLprep + $authcid = \str_replace(array('=',','), array('=3D','=2C'), $authcid); + + $this->nonce = \bin2hex(\random_bytes(16)); + $this->passphrase = $passphrase; + $this->gs2_header = 'n,' . (empty($authzid) ? '' : 'a=' . $authzid) . ','; + $this->auth_message = "n={$authcid},r={$this->nonce}"; + return $this->encode($this->gs2_header . $this->auth_message); + } + + public function challenge(string $challenge) : string + { + $challenge = $this->decode($challenge); + $values = static::parseMessage($challenge); + + if (empty($values['r'])) { + throw new \Exception('Server nonce not found'); + } + if (empty($values['s'])) { + throw new \Exception('Server salt not found'); + } + if (empty($values['i'])) { + throw new \Exception('Server iterator not found'); + } + + if (\substr($values['r'], 0, \strlen($this->nonce)) !== $this->nonce) { + throw new \Exception('Server invalid nonce'); + } + + $salt = \base64_decode($values['s']); + if (!$salt) { + throw new \Exception('Server invalid salt'); + } + + $pass = \hash_pbkdf2($this->algo, $this->passphrase, $salt, \intval($values['i']), 0, true); + $this->passphrase = null; + + $ckey = \hash_hmac($this->algo, 'Client Key', $pass, true); + $skey = \hash($this->algo, $ckey, true); + + $cfmb = 'c='.\base64_encode($this->gs2_header).',r='.$values['r']; + $amsg = "{$this->auth_message},{$challenge},{$cfmb}"; + + $csig = \hash_hmac($this->algo, $amsg, $skey, true); + $proof = \base64_encode($ckey ^ $csig); + + $skey = \hash_hmac($this->algo, 'Server Key', $pass, true); + $this->server_key = \hash_hmac($this->algo, $amsg, $skey, true); + + return $this->encode("{$cfmb},p={$proof}"); + } + + public function verify(string $data) : bool + { + $v = static::parseMessage($this->decode($data)); + if (empty($v['v'])) { + throw new \Exception('Server signature not found'); + } + if (\base64_encode($this->server_key) !== $v['v']) { + throw new \Exception('Server signature invalid'); + } + return true; + } + + protected static function parseMessage(string $msg) : array + { + if ($msg && \preg_match_all('#(\w+)\=(?:"([^"]+)"|([^,]+))#', $msg, $m)) { + return \array_combine( + $m[1], + \array_replace( + \array_filter($m[2]), + \array_filter($m[3]) + ) + ); + } + return array(); + } + + public static function isSupported(string $param) : bool + { + $param = \str_replace('sha-', 'sha', \strtolower($param)); + return \in_array($param, \hash_algos()); + } + +}