diff --git a/modules/imap/hm-imap.php b/modules/imap/hm-imap.php index 9b4ffda3c0..cd35109525 100644 --- a/modules/imap/hm-imap.php +++ b/modules/imap/hm-imap.php @@ -164,11 +164,12 @@ class Hm_IMAP extends Hm_IMAP_Cache { /* current selected mailbox status */ public $folder_state = false; - + private $scramAuthenticator; /** * constructor */ public function __construct() { + $this->scramAuthenticator = new ScramAuthenticator(); } /* ------------------ CONNECT/AUTH ------------------------------------- */ @@ -233,40 +234,57 @@ public function disconnect() { fclose($this->handle); } } - /** - * authenticate the username/password + * Authenticate the username/password * @param string $username IMAP login name * @param string $password IMAP password - * @return bool true on sucessful login + * @return bool true on successful login */ public function authenticate($username, $password) { $this->get_capability(); if (!$this->tls) { $this->starttls(); + } + $scramMechanisms = [ + 'scram-sha-1', 'scram-sha-1-plus', + 'scram-sha-256', 'scram-sha-256-plus', + 'scram-sha-224', 'scram-sha-224-plus', + 'scram-sha-384', 'scram-sha-384-plus', + 'scram-sha-512', 'scram-sha-512-plus' + ]; + if (in_array(strtolower($this->auth), $scramMechanisms)) { + $scramAlgorithm = strtoupper($this->auth); + if ($this->scramAuthenticator->authenticateScram( + $scramAlgorithm, + $username, + $password, + [$this, 'get_response'], + [$this, 'send_command'] + )) { + return true; // Authentication successful + } } switch (strtolower($this->auth)) { - case 'cram-md5': $this->banner = $this->fgets(1024); - $cram1 = 'AUTHENTICATE CRAM-MD5'."\r\n"; + $cram1 = 'AUTHENTICATE CRAM-MD5' . "\r\n"; $this->send_command($cram1); $response = $this->get_response(); $challenge = base64_decode(substr(trim($response[0]), 1)); - $pass = str_repeat(chr(0x00), (64-strlen($password))); + $pass = str_repeat(chr(0x00), (64 - strlen($password))); $ipad = str_repeat(chr(0x36), 64); $opad = str_repeat(chr(0x5c), 64); - $digest = bin2hex(pack("H*", md5(($pass ^ $opad).pack("H*", md5(($pass ^ $ipad).$challenge))))); - $challenge_response = base64_encode($username.' '.$digest); - fputs($this->handle, $challenge_response."\r\n"); + $digest = bin2hex(pack("H*", md5(($pass ^ $opad) . pack("H*", md5(($pass ^ $ipad) . $challenge))))); + $challenge_response = base64_encode($username . ' ' . $digest); + fputs($this->handle, $challenge_response . "\r\n"); break; case 'xoauth2': - $challenge = 'user='.$username.chr(1).'auth=Bearer '.$password.chr(1).chr(1); - $command = 'AUTHENTICATE XOAUTH2 '.base64_encode($challenge)."\r\n"; + $challenge = 'user=' . $username . chr(1) . 'auth=Bearer ' . $password . chr(1) . chr(1); + $command = 'AUTHENTICATE XOAUTH2 ' . base64_encode($challenge) . "\r\n"; $this->send_command($command); break; default: - $login = 'LOGIN "'.str_replace(array('\\', '"'), array('\\\\', '\"'), $username).'" "'.str_replace(array('\\', '"'), array('\\\\', '\"'), $password). "\"\r\n"; + $login = 'LOGIN "' . str_replace(array('\\', '"'), array('\\\\', '\"'), $username) . '" "' . str_replace(array('\\', '"'), array('\\\\', '\"'), $password) . "\"\r\n"; $this->send_command($login); break; } @@ -282,26 +300,24 @@ public function authenticate($username, $password) { $this->banner = $res[0]; } } - if (stristr($response, 'A'.$this->command_count.' OK')) { + if (stristr($response, 'A' . $this->command_count . ' OK')) { $authed = true; $this->state = 'authenticated'; - } - elseif (strtolower($this->auth) == 'xoauth2' && preg_match("/^\+ ([a-zA-Z0-9=]+)$/", $response, $matches)) { + } elseif (strtolower($this->auth) == 'xoauth2' && preg_match("/^\+ ([a-zA-Z0-9=]+)$/", $response, $matches)) { $this->send_command("\r\n", true); $this->get_response(); } } if ($authed) { - $this->debug[] = 'Logged in successfully as '.$username; + $this->debug[] = 'Logged in successfully as ' . $username; $this->get_capability(); $this->enable(); - //$this->enable_compression(); - } - else { - $this->debug[] = 'Log in for '.$username.' FAILED'; + } else { + $this->debug[] = 'Log in for ' . $username . ' FAILED'; } return $authed; } + /** * attempt starttls diff --git a/modules/scram/scram.php b/modules/scram/scram.php new file mode 100644 index 0000000000..66b1ba2791 --- /dev/null +++ b/modules/scram/scram.php @@ -0,0 +1,105 @@ + 'sha1', + 'sha1' => 'sha1', + 'sha-224' => 'sha224', + 'sha224' => 'sha224', + 'sha-256' => 'sha256', + 'sha256' => 'sha256', + 'sha-384' => 'sha384', + 'sha384' => 'sha384', + 'sha-512' => 'sha512', + 'sha512' => 'sha512' + ); + + private function getHashAlgorithm($scramAlgorithm) { + $parts = explode('-', strtolower($scramAlgorithm)); + return $this->hashes[$parts[1]] ?? 'sha1'; // Default to sha1 if the algorithm is not found + } + private function log($message) { + // Use Hm_Debug to add the debug message + Hm_Debug::add(sprintf($message)); + } + public function generateClientProof($username, $password, $salt, $clientNonce, $serverNonce, $algorithm) { + $iterations = 4096; + $keyLength = strlen(hash($algorithm, '', true)); // Dynamically determine key length based on algorithm + + $passwordBytes = hash($algorithm, $password, true); + $saltedPassword = hash_pbkdf2($algorithm, $passwordBytes, $salt, $iterations, $keyLength, true); + $clientKey = hash_hmac($algorithm, "Client Key", $saltedPassword, true); + $storedKey = hash($algorithm, $clientKey, true); + $authMessage = 'n=' . $username . ',r=' . $clientNonce . ',s=' . base64_encode($salt) . ',r=' . $serverNonce; + $clientSignature = hash_hmac($algorithm, $authMessage, $storedKey, true); + $clientProof = base64_encode($clientKey ^ $clientSignature); + $this->log("Client proof generated successfully"); + return $clientProof; + } + + public function authenticateScram($scramAlgorithm, $username, $password, $getServerResponse, $sendCommand) { + $algorithm = $this->getHashAlgorithm($scramAlgorithm); + + // Send initial SCRAM command + $scramCommand = 'AUTHENTICATE ' . $scramAlgorithm . "\r\n"; + $sendCommand($scramCommand); + $response = $getServerResponse(); + if (!empty($response) && substr($response[0], 0, 2) == '+ ') { + $this->log("Received server challenge: " . $response[0]); + // Extract salt and server nonce from the server's challenge + $serverChallenge = base64_decode(substr($response[0], 2)); + $parts = explode(',', $serverChallenge); + $serverNonce = base64_decode(substr($parts[0], strpos($parts[0], "=") + 1)); + $salt = base64_decode(substr($parts[1], strpos($parts[1], "=") + 1)); + + // Generate client nonce + $clientNonce = base64_encode(random_bytes(32)); + $this->log("Generated client nonce: " . $clientNonce); + + // Calculate client proof + $clientProof = $this->generateClientProof($username, $password, $salt, $clientNonce, $serverNonce, $algorithm); + + // Construct client final message + $channelBindingData = (stripos($scramAlgorithm, 'plus') !== false) ? 'c=' . base64_encode('tls-unique') . ',' : 'c=biws,'; + $clientFinalMessage = $channelBindingData . 'r=' . $serverNonce . $clientNonce . ',p=' . $clientProof; + $clientFinalMessageEncoded = base64_encode($clientFinalMessage); + $this->log("Sending client final message: " . $clientFinalMessageEncoded); + // Send client final message to server + $sendCommand($clientFinalMessageEncoded . "\r\n"); + + // Verify server's response + $response = $getServerResponse(); + if (!empty($response) && substr($response[0], 0, 2) == '+ ') { + $serverFinalMessage = base64_decode(substr($response[0], 2)); + $parts = explode(',', $serverFinalMessage); + $serverProof = substr($parts[0], strpos($parts[0], "=") + 1); + + // Generate server key + $passwordBytes = hash($algorithm, $password, true); + $saltedPassword = hash_pbkdf2($algorithm, $passwordBytes, $salt, 4096, strlen(hash($algorithm, '', true)), true); + $serverKey = hash_hmac($algorithm, "Server Key", $saltedPassword, true); + + // Calculate server signature + $authMessage = 'n=' . $username . ',r=' . $clientNonce . ',s=' . base64_encode($salt) . ',r=' . $serverNonce; + $serverSignature = base64_encode(hash_hmac($algorithm, $authMessage, $serverKey, true)); + + // Compare server signature with server proof + if ($serverSignature === $serverProof) { + $this->log("SCRAM authentication successful"); + return true; // Authentication successful if they match + } else { + $this->log("SCRAM authentication failed: Server signature mismatch"); + } + } else { + $this->log("SCRAM authentication failed: Invalid server final response"); + } + } else { + $this->log("SCRAM authentication failed: Invalid server challenge"); + } + return false; // Authentication failed + } +} + + +?> \ No newline at end of file diff --git a/modules/smtp/hm-smtp.php b/modules/smtp/hm-smtp.php index eb0ea73cc5..16671cfe15 100644 --- a/modules/smtp/hm-smtp.php +++ b/modules/smtp/hm-smtp.php @@ -79,9 +79,10 @@ class Hm_SMTP { private $password; public $state; private $request_auths = array(); + private $scramAuthenticator; function __construct($conf) { - + $this->scramAuthenticator = new ScramAuthenticator(); $this->hostname = php_uname('n'); if (preg_match("/:\d+$/", $this->hostname)) { $this->hostname = substr($this->hostname, 0, strpos($this->hostname, ':')); @@ -108,7 +109,20 @@ function __construct($conf) { if (!$this->tls) { $this->starttls = true; } - $this->request_auths = array('cram-md5', 'login', 'plain'); + $this->request_auths = array( + 'scram-sha-1', + 'scram-sha-1-plus', + 'scram-sha-256', + 'scram-sha-256-plus', + 'scram-sha-224', + 'scram-sha-224-plus', + 'scram-sha-384', + 'scram-sha-384-plus', + 'scram-sha-512', + 'scram-sha-512-plus', + 'cram-md5', + 'login', + 'plain'); if (isset($conf['auth'])) { array_unshift($this->request_auths, $conf['auth']); } @@ -315,86 +329,93 @@ function choose_auth() { } return trim($this->supports_auth[0]); } - - /** - * authenticate the username and password to the server - */ function authenticate($username, $password, $mech) { - $result = false; - switch (strtolower($mech)) { - case 'external': - $command = 'AUTH EXTERNAL '.base64_encode($username); - $this->send_command($command); - break; - case 'xoauth2': - $challenge = 'user='.$username.chr(1).'auth=Bearer '.$password.chr(1).chr(1); - $command = 'AUTH XOAUTH2 '.base64_encode($challenge); - $this->send_command($command); - break; - case 'cram-md5': - $command = 'AUTH CRAM-MD5'; - $this->send_command($command); - $response = $this->get_response(); - if (empty($response) || !isset($response[0][1][0]) || $this->compare_response($response,'334') != 0) { - $result = 'FATAL: SMTP server does not support AUTH CRAM-MD5'; - } - else { - $challenge = base64_decode(trim($response[0][1][0])); - $password .= str_repeat(chr(0x00), (64-strlen($password))); - $ipad = str_repeat(chr(0x36), 64); - $opad = str_repeat(chr(0x5c), 64); - $digest = bin2hex(pack('H*', md5(($password ^ $opad).pack('H*', md5(($password ^ $ipad).$challenge))))); - $command = base64_encode($username.' '.$digest); + $mech = strtolower($mech); + if (substr($mech, 0, 6) == 'scram-') { + $result = $this->scramAuthenticator->authenticateScram( + strtoupper($mech), + $username, + $password, + [$this, 'get_response'], + [$this, 'send_command'] + ); + if ($result) { + return 'Authentication successful'; + } + return 'Authentication failed'; + } else { + switch ($mech) { + case 'external': + $command = 'AUTH EXTERNAL '.base64_encode($username); $this->send_command($command); - } - break; - case 'ntlm': - $command = 'AUTH NTLM '.$this->build_ntlm_type_one(); - $this->send_command($command); - $response = $this->get_response(); - if (empty($response) || !isset($response[0][1][0]) || $this->compare_response($response,'334') != 0) { - $result = 'FATAL: SMTP server does not support AUTH NTLM'; - } - else { - $ntlm_res = $this->parse_ntlm_type_two($response[0][1][0]); - $command = $this->build_ntlm_type_three($ntlm_res, $username, $password); + break; + case 'xoauth2': + $challenge = 'user='.$username.chr(1).'auth=Bearer '.$password.chr(1).chr(1); + $command = 'AUTH XOAUTH2 '.base64_encode($challenge); $this->send_command($command); - } - break; - case 'login': - $command = 'AUTH LOGIN'; - $this->send_command($command); - $response = $this->get_response(); - if (empty($response) || $this->compare_response($response,'334') != 0) { - $result = 'FATAL: SMTP server does not support AUTH LOGIN'; - } - else { - $command = base64_encode($username); + break; + case 'cram-md5': + $command = 'AUTH CRAM-MD5'; + $this->send_command($command); + $response = $this->get_response(); + if (empty($response) || !isset($response[0][1][0]) || $this->compare_response($response,'334') != 0) { + $result = 'FATAL: SMTP server does not support AUTH CRAM-MD5'; + } else { + $challenge = base64_decode(trim($response[0][1][0])); + $password .= str_repeat(chr(0x00), (64-strlen($password))); + $ipad = str_repeat(chr(0x36), 64); + $opad = str_repeat(chr(0x5c), 64); + $digest = bin2hex(pack('H*', md5(($password ^ $opad).pack('H*', md5(($password ^ $ipad).$challenge))))); + $command = base64_encode($username.' '.$digest); + $this->send_command($command); + } + break; + case 'ntlm': + $command = 'AUTH NTLM '.$this->build_ntlm_type_one(); + $this->send_command($command); + $response = $this->get_response(); + if (empty($response) || !isset($response[0][1][0]) || $this->compare_response($response,'334') != 0) { + $result = 'FATAL: SMTP server does not support AUTH NTLM'; + } else { + $ntlm_res = $this->parse_ntlm_type_two($response[0][1][0]); + $command = $this->build_ntlm_type_three($ntlm_res, $username, $password); + $this->send_command($command); + } + break; + case 'login': + $command = 'AUTH LOGIN'; $this->send_command($command); $response = $this->get_response(); if (empty($response) || $this->compare_response($response,'334') != 0) { - $result = 'FATAL: SMTP server does not support AUTH LOGIN'; + $result = 'FATAL: SMTP server does not support AUTH LOGIN'; + } else { + $command = base64_encode($username); + $this->send_command($command); + $response = $this->get_response(); + if (empty($response) || $this->compare_response($response,'334') != 0) { + $result = 'FATAL: SMTP server does not support AUTH LOGIN'; + } + $command = base64_encode($password); + $this->send_command($command); } - $command = base64_encode($password); + break; + case 'plain': + $command = 'AUTH PLAIN '.base64_encode("\0".$username."\0".$password); $this->send_command($command); - } - break; - case 'plain': - $command = 'AUTH PLAIN '.base64_encode("\0".$username."\0".$password); - $this->send_command($command); - break; - default: - $result = 'FATAL: Unknown SMTP AUTH mechanism: '.$mech; - break; + break; + default: + $result = 'FATAL: Unknown SMTP AUTH mechanism: '.$mech; + break; + } } + if (!$result) { $result = 'An error occurred authenticating to the SMTP server'; $res = $this->get_response(); if ($this->compare_response($res, '235') == 0) { $this->state = 'authed'; $result = false; - } - else { + } else { $result = 'Authorization failure'; if (isset($res[0][1])) { $result .= ': '.implode(' ', $res[0][1]); diff --git a/tests/phpunit/imap_commands.php b/tests/phpunit/imap_commands.php index caf47adc41..a04cd6b49d 100644 --- a/tests/phpunit/imap_commands.php +++ b/tests/phpunit/imap_commands.php @@ -2,7 +2,7 @@ return array( 'A1 CAPABILITY' => - "* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=CRAM-MD5\r\n", + "* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=CRAM-MD5 AUTH=SCRAM-SHA-256-PLUS\r\n", 'A3 CAPABILITY' => "* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=". @@ -32,7 +32,18 @@ 'dGVzdHVzZXIgMGYxMzE5YmIxMzMxOWViOWU4ZDdkM2JiZDJiZDJlOTQ=' => "A2 OK authentication successful\r\n", + + 'A2 AUTHENTICATE SCRAM-SHA-256' => + "+ r=fyko+d2lbbFgONRv9qkxdawL,z=SCRAM-SHA-256,c=biws,n,,\r\n", + 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL1qkxdawL,p=YzQxNmVmY2ViMDAxYzdmNTkxOWFlZDIyNTgzM2NlZDBlZjBiNTNkZjUxNTQ1ZmZmNmY5NTlkOGZjNjYxYWEyNQ==' => + "A2 OK authentication successful\r\n", + + 'A2 AUTHENTICATE SCRAM-SHA-256-PLUS' => + "+ r=fyko+d2lbbFgONRv9qkxdawL,z=SCRAM-SHA-256-PLUS,c=tls-unique,n,,\r\n", + + 'c=tls-unique,r=fyko+d2lbbFgONRv9qkxdawL1qkxdawL,p=YzQxNmVmY2ViMDAxYzdmNTkxOWFlZDIyNTgzM2NlZDBlZjBiNTNkZjUxNTQ1ZmZmNmY5NTlkOGZjNjYxYWEyNQ==' => + "A2 OK authentication successful\r\n", 'A2 AUTHENTICATE XOAUTH2 dXNlcj10ZXN0dXNlcgFhdXRoPUJlYXJlciB0ZXN0cGFzcwEB' => "+ V1WTI5dENnPT0BAQ==\r\n", diff --git a/tests/phpunit/modules/imap/hm-imap.php b/tests/phpunit/modules/imap/hm-imap.php index 9b3593d5d0..73b34d0fd5 100644 --- a/tests/phpunit/modules/imap/hm-imap.php +++ b/tests/phpunit/modules/imap/hm-imap.php @@ -94,6 +94,123 @@ public function test_authenticate_cram() { $res = $this->debug(); $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); } +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_1() { + $this->reset(); + $this->config['auth'] = 'scram-sha-1'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_1_plus() { + $this->reset(); + $this->config['auth'] = 'scram-sha-1-plus'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_256() { + $this->reset(); + $this->config['auth'] = 'scram-sha-256'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_256_plus() { + $this->reset(); + $this->config['auth'] = 'scram-sha-256-plus'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_224_plus() { + $this->reset(); + $this->config['auth'] = 'scram-sha-224-plus'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_224() { + $this->reset(); + $this->config['auth'] = 'scram-sha-224'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_384_plus() { + $this->reset(); + $this->config['auth'] = 'scram-sha-384-plus'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_384() { + $this->reset(); + $this->config['auth'] = 'scram-sha-384'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_512() { + $this->reset(); + $this->config['auth'] = 'scram-sha-512'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} +/** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ +public function test_authenticate_scram_sha_512_plus() { + $this->reset(); + $this->config['auth'] = 'scram-sha-512-plus'; + $this->connect(); + $res = $this->debug(); + $this->assertEquals('Logged in successfully as testuser', $res['debug'][2]); +} + + /** * @preserveGlobalState disabled * @runInSeparateProcess