From 5a85446779c552ea76e2c85dae6b937def25380f Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Sun, 20 Dec 2009 05:15:20 +0000 Subject: [PATCH] Initial implementation of CRAM-MD5 support for IMAP and SMTP. Patch contributed by Russ Weeks in <605ac1c0-808a-4f67-8c4d-736eec9587f8@e27g2000yqd.googlegroups.com> --- res/layout/account_setup_incoming.xml | 10 ++ res/values/strings.xml | 1 + .../activity/setup/AccountSetupIncoming.java | 27 +++- src/com/fsck/k9/mail/store/ImapStore.java | 123 ++++++++++++++++-- .../fsck/k9/mail/transport/SmtpTransport.java | 58 ++++++++- 5 files changed, 203 insertions(+), 16 deletions(-) diff --git a/res/layout/account_setup_incoming.xml b/res/layout/account_setup_incoming.xml index 561153f25..300348072 100644 --- a/res/layout/account_setup_incoming.xml +++ b/res/layout/account_setup_incoming.xml @@ -62,6 +62,16 @@ android:id="@+id/account_security_type" android:layout_height="wrap_content" android:layout_width="fill_parent" /> + + WebDAV (Exchange) server Port Security type + Authentication type None SSL (if available) SSL (always) diff --git a/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java b/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java index 9e7ad12d0..02b5b4c5f 100644 --- a/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java +++ b/src/com/fsck/k9/activity/setup/AccountSetupIncoming.java @@ -59,6 +59,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener private EditText mServerView; private EditText mPortView; private Spinner mSecurityTypeView; + private Spinner mAuthTypeView; private EditText mImapPathPrefixView; private Button mImapFolderDrafts; private Button mImapFolderSent; @@ -99,6 +100,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mServerView = (EditText)findViewById(R.id.account_server); mPortView = (EditText)findViewById(R.id.account_port); mSecurityTypeView = (Spinner)findViewById(R.id.account_security_type); + mAuthTypeView = (Spinner)findViewById(R.id.account_auth_type); mImapPathPrefixView = (EditText)findViewById(R.id.imap_path_prefix); mImapFolderDrafts = (Button)findViewById(R.id.account_imap_folder_drafts); mImapFolderSent = (Button)findViewById(R.id.account_imap_folder_sent); @@ -126,11 +128,22 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener new SpinnerOption(4, getString(R.string.account_setup_incoming_security_tls_label)), }; + SpinnerOption authTypes[] = + { + new SpinnerOption(0, "PLAIN" ), + new SpinnerOption(1, "CRAM_MD5" ) + }; + ArrayAdapter securityTypesAdapter = new ArrayAdapter(this, android.R.layout.simple_spinner_item, securityTypes); securityTypesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); mSecurityTypeView.setAdapter(securityTypesAdapter); + ArrayAdapter authTypesAdapter = new ArrayAdapter(this, + android.R.layout.simple_spinner_item, authTypes); + authTypesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mAuthTypeView.setAdapter(authTypesAdapter); + /* * Updates the port when the user changes the security type. This allows * us to show a reasonable default which the user can change. @@ -228,6 +241,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener findViewById(R.id.imap_folder_setup_section).setVisibility(View.GONE); findViewById(R.id.webdav_path_prefix_section).setVisibility(View.GONE); findViewById(R.id.webdav_path_debug_section).setVisibility(View.GONE); + findViewById(R.id.account_auth_type).setVisibility(View.GONE); mAccount.setDeletePolicy(Account.DELETE_POLICY_NEVER); @@ -261,6 +275,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener /** Hide the unnecessary fields */ findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE); findViewById(R.id.imap_folder_setup_section).setVisibility(View.GONE); + findViewById(R.id.account_auth_type).setVisibility(View.GONE); if (uri.getPath() != null && uri.getPath().length() > 0) { String[] pathParts = uri.getPath().split("\\|"); @@ -433,9 +448,19 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener path = path + "|" + mWebdavMailboxPathView.getText(); } + final String userInfo; + if (mAccountSchemes[securityType].startsWith("imap")) + { + String authType = ((SpinnerOption)mAuthTypeView.getSelectedItem()).label; + userInfo = authType + ":" + mUsernameView.getText() + ":" + mPasswordView.getText(); + } + else + { + userInfo = mUsernameView.getText() + ":" + mPasswordView.getText(); + } URI uri = new URI( mAccountSchemes[securityType], - mUsernameView.getText() + ":" + mPasswordView.getText(), + userInfo, mServerView.getText().toString(), Integer.parseInt(mPortView.getText().toString()), path, // path diff --git a/src/com/fsck/k9/mail/store/ImapStore.java b/src/com/fsck/k9/mail/store/ImapStore.java index f897a1e36..999f4b24b 100644 --- a/src/com/fsck/k9/mail/store/ImapStore.java +++ b/src/com/fsck/k9/mail/store/ImapStore.java @@ -16,12 +16,19 @@ import com.beetstra.jutf7.CharsetProvider; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; + + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.binary.Hex; + import java.io.*; import java.net.*; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.charset.Charset; import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.Security; import java.util.*; @@ -51,6 +58,8 @@ public class ImapStore extends Store public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; + private enum AuthType { PLAIN, CRAM_MD5 }; + private static final int IDLE_READ_TIMEOUT = 29 * 60 * 1000; // 29 minutes private static final int IDLE_REFRESH_INTERVAL = 20 * 60 * 1000; // 20 minutes @@ -66,6 +75,7 @@ public class ImapStore extends Store private String mUsername; private String mPassword; private int mConnectionSecurity; + private AuthType mAuthType; private volatile String mPathPrefix; private volatile String mCombinedPrefix = null; private volatile String mPathDelimeter; @@ -87,11 +97,11 @@ public class ImapStore extends Store private HashMap mFolderCache = new HashMap(); /** - * imap://user:password@server:port CONNECTION_SECURITY_NONE - * imap+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL - * imap+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED - * imap+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED - * imap+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL + * imap://auth:user:password@server:port CONNECTION_SECURITY_NONE + * imap+tls://auth:user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL + * imap+tls+://auth:user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED + * imap+ssl+://auth:user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED + * imap+ssl://auth:user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL * * @param _uri */ @@ -147,12 +157,10 @@ public class ImapStore extends Store if (uri.getUserInfo() != null) { - String[] userInfoParts = uri.getUserInfo().split(":", 2); - mUsername = userInfoParts[0]; - if (userInfoParts.length > 1) - { - mPassword = userInfoParts[1]; - } + String[] userInfoParts = uri.getUserInfo().split(":"); + mAuthType = AuthType.valueOf(userInfoParts[0]); + mUsername = userInfoParts[1]; + mPassword = userInfoParts[2]; } if ((uri.getPath() != null) && (uri.getPath().length() > 0)) @@ -1899,9 +1907,14 @@ public class ImapStore extends Store try { - // TODO eventually we need to add additional authentication - // options such as SASL - executeSimpleCommand("LOGIN \"" + escapeString(mUsername) + "\" \"" + escapeString(mPassword) + "\"", true); + if ( mAuthType == AuthType.CRAM_MD5 ) + { + authCramMD5(); + } + else if ( mAuthType == AuthType.PLAIN ) + { + executeSimpleCommand("LOGIN \"" + escapeString(mUsername) + "\" \"" + escapeString(mPassword) + "\"", true); + } authSuccess = true; } catch (ImapException ie) @@ -2002,6 +2015,88 @@ public class ImapStore extends Store } } + protected void authCramMD5() throws AuthenticationFailedException, MessagingException + { + try + { + String tag = sendCommand("AUTHENTICATE CRAM-MD5", false); + byte[] buf = new byte[ 1024 ]; + int b64NonceLen = 0; + for ( int i = 0; i < buf.length; i++ ) + { + buf[ i ] = (byte)mIn.read(); + if ( buf[i] == 0x0a ) + { + b64NonceLen = i; + break; + } + } + if ( b64NonceLen == 0 ) + { + throw new AuthenticationFailedException( "Error negotiating CRAM-MD5: nonce too long." ); + } + byte[] b64NonceTrim = new byte[ b64NonceLen - 2 ]; + System.arraycopy(buf, 1, b64NonceTrim, 0, b64NonceLen - 2); + byte[] nonce = Base64.decodeBase64(b64NonceTrim); + if ( K9.DEBUG ) + { + Log.d(K9.LOG_TAG, "Got nonce: " + new String( b64NonceTrim, "US-ASCII" ) ); + Log.d(K9.LOG_TAG, "Plaintext nonce: " + new String( nonce, "US-ASCII" ) ); + } + byte[] ipad = new byte[64]; + byte[] opad = new byte[64]; + byte[] secretBytes = mPassword.getBytes("US-ASCII"); + MessageDigest md = MessageDigest.getInstance("MD5"); + if ( secretBytes.length > 64 ) + { + secretBytes = md.digest(secretBytes); + } + System.arraycopy(secretBytes, 0, ipad, 0, secretBytes.length); + System.arraycopy(secretBytes, 0, opad, 0, secretBytes.length); + for ( int i = 0; i < ipad.length; i++ ) ipad[i] ^= 0x36; + for ( int i = 0; i < opad.length; i++ ) opad[i] ^= 0x5c; + md.update(ipad); + byte[] firstPass = md.digest(nonce); + md.update(opad); + byte[] result = md.digest(firstPass); + String plainCRAM = mUsername + " " + new String(Hex.encodeHex(result)); + byte[] b64CRAM = Base64.encodeBase64(plainCRAM.getBytes("US-ASCII")); + if ( K9.DEBUG ) + { + Log.d(K9.LOG_TAG, "Username == " + mUsername); + Log.d( K9.LOG_TAG, "plainCRAM: " + plainCRAM ); + Log.d( K9.LOG_TAG, "b64CRAM: " + new String(b64CRAM, "US-ASCII")); + } + mOut.write( b64CRAM ); + mOut.write( new byte[] { 0x0d, 0x0a } ); + mOut.flush(); + int respLen = 0; + for ( int i = 0; i < buf.length; i++ ) + { + buf[ i ] = (byte)mIn.read(); + if ( buf[i] == 0x0a ) + { + respLen = i; + break; + } + } + String toMatch = tag + " OK"; + String respStr = new String( buf, 0, respLen ); + if ( !respStr.startsWith( toMatch ) ) + { + throw new AuthenticationFailedException( "CRAM-MD5 error: " + respStr ); + } + } + catch ( IOException ioe ) + { + throw new AuthenticationFailedException( "CRAM-MD5 Auth Failed." ); + } + catch ( NoSuchAlgorithmException nsae ) + { + throw new AuthenticationFailedException( "MD5 Not Available." ); + } + } + protected void setReadTimeout(int millis) throws SocketException { mSocket.setSoTimeout(millis); diff --git a/src/com/fsck/k9/mail/transport/SmtpTransport.java b/src/com/fsck/k9/mail/transport/SmtpTransport.java index 4c0d45bee..ec1fbc8bb 100644 --- a/src/com/fsck/k9/mail/transport/SmtpTransport.java +++ b/src/com/fsck/k9/mail/transport/SmtpTransport.java @@ -18,7 +18,10 @@ import java.io.IOException; import java.io.OutputStream; import java.net.*; import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import org.apache.commons.codec.binary.Hex; import java.util.ArrayList; import java.util.List; @@ -222,6 +225,7 @@ public class SmtpTransport extends Transport */ boolean authLoginSupported = false; boolean authPlainSupported = false; + boolean authCramMD5Supported = false; for (String result : results) { if (result.matches(".*AUTH.*LOGIN.*$") == true) @@ -232,12 +236,20 @@ public class SmtpTransport extends Transport { authPlainSupported = true; } + if (result.matches(".*AUTH.*CRAM-MD5.*$") == true) + { + authCramMD5Supported = true; + } } if (mUsername != null && mUsername.length() > 0 && mPassword != null && mPassword.length() > 0) { - if (authPlainSupported) + if (authCramMD5Supported) + { + saslAuthCramMD5(mUsername, mPassword); + } + else if (authPlainSupported) { saslAuthPlain(mUsername, mPassword); } @@ -484,4 +496,48 @@ public class SmtpTransport extends Transport throw me; } } + + private void saslAuthCramMD5(String username, String password) throws MessagingException, + AuthenticationFailedException, IOException + { + List respList = executeSimpleCommand( "AUTH CRAM-MD5" ); + if ( respList.size() != 1 ) throw new AuthenticationFailedException( "Unable to negotiate CRAM-MD5" ); + String b64Nonce = respList.get(0); + byte[] nonce = Base64.decodeBase64( b64Nonce.getBytes("US-ASCII") ); + byte[] ipad = new byte[64]; + byte[] opad = new byte[64]; + byte[] secretBytes = password.getBytes("US-ASCII"); + MessageDigest md; + try + { + md = MessageDigest.getInstance("MD5"); + } + catch ( NoSuchAlgorithmException nsae ) + { + throw new AuthenticationFailedException( "MD5 Not Available." ); + } + if ( secretBytes.length > 64 ) + { + secretBytes = md.digest(secretBytes); + } + System.arraycopy(secretBytes, 0, ipad, 0, secretBytes.length); + System.arraycopy(secretBytes, 0, opad, 0, secretBytes.length); + for ( int i = 0; i < ipad.length; i++ ) ipad[i] ^= 0x36; + for ( int i = 0; i < opad.length; i++ ) opad[i] ^= 0x5c; + md.update(ipad); + byte[] firstPass = md.digest(nonce); + md.update(opad); + byte[] result = md.digest(firstPass); + String plainCRAM = username + " " + new String(Hex.encodeHex(result)); + byte[] b64CRAM = Base64.encodeBase64(plainCRAM.getBytes("US-ASCII")); + String b64CRAMString = new String( b64CRAM, "US-ASCII" ); + try + { + executeSimpleCommand( b64CRAMString ); + } + catch ( MessagingException me ) + { + throw new AuthenticationFailedException( "Unable to negotiate MD5 CRAM" ); + } + } }