Added support for SCRAM-SHA-1 & SCRAM-SHA-256

https://github.com/RainLoop/rainloop-webmail/issues/1914
This commit is contained in:
djmaze 2021-03-29 20:51:14 +02:00
parent d0dc257619
commit 68d103b3c1
6 changed files with 330 additions and 118 deletions

View file

@ -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

View file

@ -0,0 +1,52 @@
<?php
namespace SnappyMail;
abstract class SASL
{
public
$base64 = false;
abstract public function authenticate(string $authcid, string $passphrase, ?string $authzid = null);
public function challenge(string $challenge) : string
{
return null;
}
public function verify(string $data) : bool
{
return false;
}
final public static function factory(string $type) : void
{
if (\preg_match('/^([A-Z2]+)(?:-(.+))?$/Di', $type, $m)) {
$class = __CLASS__ . "\\{$m[1]}";
if (\class_exists($class)) {
return new $class($m[2] ?? '');
}
}
throw new \Exception("Unsupported SASL mechanism type: {$type}");
}
public static function isSupported(string $type) : bool
{
if (\preg_match('/^([A-Z2]+)(?:-(.+))?$/Di', $type, $m)) {
$class = __CLASS__ . "\\{$m[1]}";
return \class_exists($class) && $class::isSupported($m[2] ?? '');
}
return false;
}
final protected function decode(string $data) : string
{
return $this->base64 ? \base64_decode($data) : $data;
}
final protected function encode(string $data) : string
{
return $this->base64 ? \base64_encode($data) : $data;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace SnappyMail\SASL;
class Cram extends \SnappyMail\SASL
{
function __construct(string $algo)
{
$algo = \strtolower($algo);
if (!\in_array($algo, \hash_algos())) {
throw new \Exception("Unsupported SASL CRAM algorithm: {$algo}");
}
$this->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());
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace SnappyMail\SASL;
class Login extends \SnappyMail\SASL
{
protected
$passphrase;
public function authenticate(string $username, string $passphrase, ?string $challenge = null) : string
{
if ($challenge && 'Username:' !== $this->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;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace SnappyMail\SASL;
class Plain extends \SnappyMail\SASL
{
public function authenticate(string $username, string $passphrase, ?string $authzid = null) : string
{
return $this->encode("{$authzid}\x00{$username}\x00{$passphrase}");
}
public static function isSupported(string $param) : bool
{
return true;
}
}

View file

@ -0,0 +1,119 @@
<?php
/**
* https://tools.ietf.org/html/rfc5802
* https://tools.ietf.org/html/rfc7677
*/
namespace SnappyMail\SASL;
class Scram extends \SnappyMail\SASL
{
protected
$algo,
$nonce,
$passphrase,
$gs2_header,
$auth_message,
$server_key;
function __construct(string $algo)
{
if (\stripos($algo, '-PLUS')) {
throw new \Exception("SASL SCRAM channel binding unsupported: {$algo}");
}
$algo = \str_replace('sha-', 'sha', \strtolower($algo));
if (!\in_array($algo, \hash_algos())) {
throw new \Exception("SASL SCRAM unsupported algorithm: {$algo}");
}
$this->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());
}
}