package eu.faircode.email; /* This file is part of FairEmail. FairEmail is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. FairEmail is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with FairEmail. If not, see . Copyright 2018-2023 by Marcel Bokhorst (M66B) */ import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_GMAIL; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_OAUTH; import android.content.Context; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.Network; import android.os.Build; import android.os.ParcelFileDescriptor; import android.security.KeyChain; import android.system.ErrnoException; import android.system.OsConstants; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import com.sun.mail.gimap.GmailSSLProvider; import com.sun.mail.iap.ProtocolException; import com.sun.mail.imap.IMAPFolder; import com.sun.mail.imap.IMAPStore; import com.sun.mail.pop3.POP3Store; import com.sun.mail.smtp.SMTPTransport; import com.sun.mail.util.MailConnectException; import com.sun.mail.util.SocketConnectException; import com.sun.mail.util.TraceOutputStream; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.net.ConnectException; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.Socket; import java.net.SocketException; import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.regex.Pattern; import javax.mail.AuthenticationFailedException; import javax.mail.Folder; import javax.mail.MessagingException; import javax.mail.NoSuchProviderException; import javax.mail.Service; import javax.mail.Session; import javax.mail.Store; import javax.mail.event.StoreListener; import javax.net.SocketFactory; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; // IMAP standards: https://imapwiki.org/Specs public class EmailService implements AutoCloseable { private Context context; private String protocol; private boolean insecure; private int purpose; private boolean ssl_harden; private boolean ssl_harden_strict; private boolean cert_strict; private boolean useip; private String ehlo; private boolean log; private boolean debug; private Properties properties; private Session isession; private Service iservice; private StoreListener listener; private ServiceAuthenticator authenticator; private RingBuffer breadcrumbs; static final int PURPOSE_CHECK = 1; static final int PURPOSE_USE = 2; static final int PURPOSE_SEARCH = 3; static final int ENCRYPTION_SSL = 0; static final int ENCRYPTION_STARTTLS = 1; static final int ENCRYPTION_NONE = 2; final static int DEFAULT_CONNECT_TIMEOUT = 20; // seconds final static boolean SEPARATE_STORE_CONNECTION = false; private final static int SEARCH_TIMEOUT = 90 * 1000; // milliseconds private final static int FETCH_SIZE = 1024 * 1024; // bytes, default 16K private final static int POOL_SIZE = 1; // connections private final static int POOL_TIMEOUT = 60 * 1000; // milliseconds, default 45 sec private final static long PROTOCOL_LOG_DURATION = 12 * 3600 * 1000L; private final static int BREADCRUMBS_SIZE = 100; private final static int MAX_IPV4 = 2; private final static int MAX_IPV6 = 1; private final static int TCP_KEEP_ALIVE_INTERVAL = 9 * 60; // seconds private static final int APPEND_BUFFER_SIZE = 4 * 1024 * 1024; // bytes private static final List SSL_PROTOCOL_INSECURE = Collections.unmodifiableList(Arrays.asList( "SSLv2", "SSLv3" )); // https://developer.android.com/reference/javax/net/ssl/SSLSocket.html#protocols private static final List SSL_PROTOCOL_BLACKLIST = Collections.unmodifiableList(Arrays.asList( "SSLv2", "SSLv3", "TLSv1", "TLSv1.1" )); private static final List SSL_PROTOCOL_BLACKLIST_STRICT = Collections.unmodifiableList(Arrays.asList( "SSLv2", "SSLv3", "TLSv1", "TLSv1.1", "TLSv1.2" )); // https://developer.android.com/reference/javax/net/ssl/SSLSocket.html#cipher-suites private static final Pattern SSL_CIPHER_BLACKLIST = Pattern.compile(".*(_DES|DH_|DSS|EXPORT|MD5|NULL|RC4|TLS_FALLBACK_SCSV).*"); private static final Pattern SSL_CIPHER_BLACKLIST_STRICT = Pattern.compile("(.*(_DES|DH_|DSS|EXPORT|MD5|NULL|RC4|TLS_FALLBACK_SCSV|RSA).*)|(.*SHA$)"); // TLS_FALLBACK_SCSV https://tools.ietf.org/html/rfc7507 // TLS_EMPTY_RENEGOTIATION_INFO_SCSV https://tools.ietf.org/html/rfc5746 private EmailService() { // Prevent instantiation } EmailService(Context context, String protocol, String realm, int encryption, boolean insecure, boolean unicode, boolean debug) throws NoSuchProviderException { this(context, protocol, realm, encryption, insecure, unicode, PURPOSE_USE, debug); } EmailService(Context context, String protocol, String realm, int encryption, boolean insecure, boolean unicode, int purpose, boolean debug) throws NoSuchProviderException { this.context = context.getApplicationContext(); this.protocol = protocol; this.insecure = insecure; this.purpose = purpose; this.debug = debug; properties = MessageHelper.getSessionProperties(unicode); long now = new Date().getTime(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); long protocol_since = prefs.getLong("protocol_since", 0); if (protocol_since == 0) prefs.edit().putLong("protocol_since", now).apply(); else if (protocol_since + PROTOCOL_LOG_DURATION < now && !BuildConfig.TEST_RELEASE) prefs.edit().putBoolean("protocol", false).apply(); this.log = prefs.getBoolean("protocol", false); this.ssl_harden = prefs.getBoolean("ssl_harden", false); this.ssl_harden_strict = prefs.getBoolean("ssl_harden_strict", false); this.cert_strict = prefs.getBoolean("cert_strict", true); boolean auth_plain = prefs.getBoolean("auth_plain", true); boolean auth_login = prefs.getBoolean("auth_login", true); boolean auth_ntlm = prefs.getBoolean("auth_ntlm", true); boolean auth_sasl = prefs.getBoolean("auth_sasl", true); boolean auth_apop = prefs.getBoolean("auth_apop", false); boolean use_top = prefs.getBoolean("use_top", true); boolean forget_top = prefs.getBoolean("forget_top", false); Log.i("Authenticate" + " plain=" + auth_plain + " login=" + auth_login + " ntlm=" + auth_ntlm + " sasl=" + auth_sasl + " apop=" + auth_apop + " use_top=" + use_top + " forget_top=" + forget_top); //properties.put("mail.event.scope", "folder"); //properties.put("mail.event.executor", executor); if (!auth_plain) properties.put("mail." + protocol + ".auth.plain.disable", "true"); if (!auth_login) properties.put("mail." + protocol + ".auth.login.disable", "true"); if (!auth_ntlm) properties.put("mail." + protocol + ".auth.ntlm.disable", "true"); if (auth_apop) properties.put("mail." + protocol + ".apop.enable", "true"); if (!use_top) properties.put("mail." + protocol + ".disabletop", "true"); if (forget_top) properties.put("mail." + protocol + ".forgettopheaders", "true"); // SASL is attempted before other authentication methods properties.put("mail." + protocol + ".sasl.enable", Boolean.toString(auth_sasl)); properties.put("mail." + protocol + ".sasl.mechanisms", "CRAM-MD5"); properties.put("mail." + protocol + ".sasl.realm", realm == null ? "" : realm); properties.put("mail." + protocol + ".auth.ntlm.domain", realm == null ? "" : realm); // writetimeout: one thread overhead int timeout = prefs.getInt("timeout", DEFAULT_CONNECT_TIMEOUT) * 1000; Log.i("Timeout=" + timeout); if (purpose == PURPOSE_SEARCH) { properties.put("mail." + protocol + ".connectiontimeout", Integer.toString(timeout)); properties.put("mail." + protocol + ".writetimeout", Integer.toString(SEARCH_TIMEOUT)); properties.put("mail." + protocol + ".timeout", Integer.toString(SEARCH_TIMEOUT)); } else { int factor = 2; if ("smtp".equals(protocol) || "smtps".equals(protocol)) factor *= 2; properties.put("mail." + protocol + ".connectiontimeout", Integer.toString(timeout)); properties.put("mail." + protocol + ".writetimeout", Integer.toString(timeout * factor)); properties.put("mail." + protocol + ".timeout", Integer.toString(timeout * factor)); } boolean idle_done = prefs.getBoolean("idle_done", true); properties.put("mail.idledone", Boolean.toString(idle_done)); if (debug && BuildConfig.DEBUG) properties.put("mail.debug.auth", "true"); boolean starttls = (encryption == ENCRYPTION_STARTTLS); if (encryption == ENCRYPTION_NONE) { properties.put("mail." + protocol + ".ssl.enable", "false"); properties.put("mail." + protocol + ".socketFactory", new SocketFactoryService()); } if ("pop3".equals(protocol) || "pop3s".equals(protocol)) { // https://javaee.github.io/javamail/docs/api/com/sun/mail/pop3/package-summary.html#properties properties.put("mail.pop3s.starttls.enable", "false"); properties.put("mail.pop3.starttls.enable", Boolean.toString(starttls)); properties.put("mail.pop3.starttls.required", Boolean.toString(starttls && !insecure)); } else if ("imap".equals(protocol) || "imaps".equals(protocol) || "gimaps".equals(protocol)) { // https://javaee.github.io/javamail/docs/api/com/sun/mail/imap/package-summary.html#properties properties.put("mail.imaps.starttls.enable", "false"); properties.put("mail.imap.starttls.enable", Boolean.toString(starttls)); properties.put("mail.imap.starttls.required", Boolean.toString(starttls && !insecure)); properties.put("mail." + protocol + ".separatestoreconnection", Boolean.toString(SEPARATE_STORE_CONNECTION)); properties.put("mail." + protocol + ".connectionpool.debug", "true"); properties.put("mail." + protocol + ".connectionpoolsize", Integer.toString(POOL_SIZE)); properties.put("mail." + protocol + ".connectionpooltimeout", Integer.toString(POOL_TIMEOUT)); properties.put("mail." + protocol + ".finalizecleanclose", "false"); //properties.put("mail." + protocol + ".closefoldersonstorefailure", "false"); // https://tools.ietf.org/html/rfc4978 // https://docs.oracle.com/javase/8/docs/api/java/util/zip/Deflater.html properties.put("mail." + protocol + ".compress.enable", "true"); //properties.put("mail.imaps.compress.level", "-1"); //properties.put("mail.imaps.compress.strategy", "0"); properties.put("mail." + protocol + ".throwsearchexception", "true"); properties.put("mail." + protocol + ".fetchsize", Integer.toString(FETCH_SIZE)); properties.put("mail." + protocol + ".peek", "true"); properties.put("mail." + protocol + ".appendbuffersize", Integer.toString(APPEND_BUFFER_SIZE)); if (!"gimaps".equals(protocol) && BuildConfig.DEBUG) properties.put("mail." + protocol + ".folder.class", IMAPFolderEx.class.getName()); } else if ("smtp".equals(protocol) || "smtps".equals(protocol)) { // https://javaee.github.io/javamail/docs/api/com/sun/mail/smtp/package-summary.html#properties properties.put("mail.smtps.starttls.enable", "false"); properties.put("mail.smtp.starttls.enable", Boolean.toString(starttls)); properties.put("mail.smtp.starttls.required", Boolean.toString(starttls && !insecure)); properties.put("mail." + protocol + ".auth", "true"); } else throw new NoSuchProviderException(protocol); } void setPartialFetch(boolean enabled) { properties.put("mail." + protocol + ".partialfetch", Boolean.toString(enabled)); } void setRawFetch(boolean enabled) { properties.put("fairemail.rawfetch", Boolean.toString(enabled)); } void setIgnoreBodyStructureSize(boolean enabled) { properties.put("mail." + protocol + ".ignorebodystructuresize", Boolean.toString(enabled)); } void setUseIp(boolean enabled, String host) { this.useip = enabled; this.ehlo = host; } void setLeaveOnServer(boolean keep) { properties.put("mail." + protocol + ".rsetbeforequit", Boolean.toString(keep)); } void set8BitMime(boolean value) { // https://datatracker.ietf.org/doc/html/rfc6532 properties.put("mail." + protocol + ".allow8bitmime", Boolean.toString(value)); } void setRestartIdleInterval(int seconds) { properties.put("mail." + protocol + ".restartidleinterval", Integer.toString(seconds)); } // https://tools.ietf.org/html/rfc3461 void setDsnNotify(String what) { properties.put("mail." + protocol + ".dsn.notify", what); } void setReporter(TraceOutputStream.IReport reporter) { properties.put("mail." + protocol + ".reporter", reporter); } void setListener(StoreListener listener) { this.listener = listener; } public void connect(EntityAccount account) throws MessagingException { connect( account.host, account.port, account.auth_type, account.provider, account.user, account.password, new ServiceAuthenticator.IAuthenticated() { @Override public void onPasswordChanged(Context context, String newPassword) { DB db = DB.getInstance(context); account.password = newPassword; int accounts = db.account().setAccountPassword(account.id, account.password, account.auth_type, account.provider); int identities = db.identity().setIdentityPassword(account.id, account.user, account.password, account.auth_type, account.auth_type, account.provider); EntityLog.log(context, EntityLog.Type.Account, account, "token refreshed=" + accounts + "/" + identities); } }, account.certificate_alias, account.fingerprint); } public void connect(EntityIdentity identity) throws MessagingException { connect( identity.host, identity.port, identity.auth_type, identity.provider, identity.user, identity.password, new ServiceAuthenticator.IAuthenticated() { @Override public void onPasswordChanged(Context context, String newPassword) { DB db = DB.getInstance(context); identity.password = newPassword; int count = db.identity().setIdentityPassword(identity.id, identity.password); EntityLog.log(context, EntityLog.Type.Account, identity.account, null, null, identity.email + " token refreshed=" + count); } }, identity.certificate_alias, identity.fingerprint); } public void connect( String host, int port, int auth, String provider, String user, String password, String certificate, String fingerprint) throws MessagingException { connect(host, port, auth, provider, user, password, null, certificate, fingerprint); } private void connect( String host, int port, int auth, String provider, String user, String password, ServiceAuthenticator.IAuthenticated intf, String certificate, String fingerprint) throws MessagingException { properties.put("fairemail.server", host); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean bind_socket = prefs.getBoolean("bind_socket", false); if (bind_socket && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) try { ConnectivityManager cm = Helper.getSystemService(context, ConnectivityManager.class); Network active = cm.getActiveNetwork(); if (active != null) { EntityLog.log(context, EntityLog.Type.Network, "Binding to" + " active=" + active); properties.put("fairemail.factory", active.getSocketFactory()); } } catch (Throwable ex) { Log.e(ex); } SSLSocketFactoryService factory = null; try { PrivateKey key = null; X509Certificate[] chain = null; if (certificate != null) { Log.i("Get client certificate alias=" + certificate); try { key = KeyChain.getPrivateKey(context, certificate); chain = KeyChain.getCertificateChain(context, certificate); } catch (Throwable ex) { Log.w(ex); } } boolean strict = ssl_harden_strict; if (strict) if ("pop3".equals(protocol) || "pop3s".equals(protocol)) strict = false; else { EmailProvider p = EmailProvider.getProviderByHost(context, host); if (p != null && "1.2".equals(p.maxtls)) { strict = false; Log.i(p.name + " maxtls=" + p.maxtls); } } boolean bc = prefs.getBoolean("bouncy_castle", false); boolean fips = prefs.getBoolean("bc_fips", false); factory = new SSLSocketFactoryService(host, insecure, ssl_harden, strict, cert_strict, bc, fips, key, chain, fingerprint); properties.put("mail." + protocol + ".ssl.socketFactory", factory); properties.put("mail." + protocol + ".socketFactory.fallback", "false"); properties.put("mail." + protocol + ".ssl.checkserveridentity", "false"); } catch (GeneralSecurityException ex) { properties.put("mail." + protocol + ".ssl.checkserveridentity", Boolean.toString(!insecure)); if (insecure) properties.put("mail." + protocol + ".ssl.trust", "*"); Log.e("Trust issues", ex); } properties.put("mail." + protocol + ".forcepasswordrefresh", "true"); authenticator = new ServiceAuthenticator(context, auth, provider, user, password, intf); if ("imap.wp.pl".equals(host)) properties.put("mail.idledone", "false"); try { if (auth == AUTH_TYPE_GMAIL || auth == AUTH_TYPE_OAUTH) { properties.put("mail." + protocol + ".auth.mechanisms", "XOAUTH2"); properties.put("mail." + protocol + ".auth.xoauth2.disable", "false"); } else properties.put("mail." + protocol + ".auth.xoauth2.disable", "true"); if (auth == AUTH_TYPE_OAUTH && "imap.mail.yahoo.com".equals(host)) properties.put("mail." + protocol + ".yahoo.guid", "FAIRMAIL_V1"); if (auth == AUTH_TYPE_OAUTH && "pop3s".equals(protocol) && "outlook.office365.com".equals(host)) properties.put("mail." + protocol + ".auth.xoauth2.two.line.authentication.format", "true"); Log.i("Connecting to " + host + ":" + port + " auth=" + auth); connect(host, port, auth, user, factory); } catch (AuthenticationFailedException ex) { //if ("outlook.office365.com".equals(host) && // "AUTHENTICATE failed.".equals(ex.getMessage())) // throw new AuthenticationFailedException( // "The Outlook IMAP server is currently not accepting logins. " + // "Synchronizing and configuring accounts will work again after Microsoft has fixed this.", // ex.getNextException()); if (!BuildConfig.PLAY_STORE_RELEASE) Log.e(ex); EntityLog.log(context, ex + "\n" + android.util.Log.getStackTraceString(ex)); if (auth == AUTH_TYPE_GMAIL || auth == AUTH_TYPE_OAUTH) { try { EntityLog.log(context, EntityLog.Type.Debug, ex + "\n" + android.util.Log.getStackTraceString(ex)); authenticator.refreshToken(true); connect(host, port, auth, user, factory); } catch (Exception ex1) { Throwable cause = ex1.getCause(); if (cause == null) Log.e(ex1); else Log.e(new Throwable(ex1.getMessage() + " error=" + cause.getMessage(), ex1)); String msg = ex.getMessage(); if (auth == AUTH_TYPE_GMAIL && msg != null && msg.endsWith("Invalid credentials (Failure)")) msg += "\n" + context.getString(R.string.title_service_token); Throwable c = ex1; while (c != null) { String m = c.getMessage(); if (!TextUtils.isEmpty(m)) msg += "\n" + m; c = c.getCause(); } throw new AuthenticationFailedException( context.getString(R.string.title_service_auth, msg), ex.getNextException()); } } else throw new AuthenticationFailedException( context.getString(R.string.title_service_auth, ex.getMessage()), ex.getNextException()); } catch (MailConnectException ex) { if (ConnectionHelper.vpnActive(context)) { MailConnectException mex = new MailConnectException( new SocketConnectException( context.getString(R.string.title_service_vpn), new Exception(), ex.getHost(), ex.getPort(), ex.getConnectionTimeout())); mex.setNextException(ex.getNextException()); throw mex; } else throw ex; } catch (MessagingException ex) { /* javax.mail.MessagingException: FY1 BAD Command Error. 10; nested exception is: com.sun.mail.iap.BadCommandException: FY1 BAD Command Error. 10 javax.mail.MessagingException: FY1 BAD Command Error. 10; nested exception is: com.sun.mail.iap.BadCommandException: FY1 BAD Command Error. 10 at com.sun.mail.imap.IMAPStore.protocolConnect(SourceFile:40) at javax.mail.Service.connect(SourceFile:31) at eu.faircode.email.EmailService._connect(SourceFile:31) at eu.faircode.email.EmailService.connect(SourceFile:99) at eu.faircode.email.EmailService.connect(SourceFile:40) at eu.faircode.email.EmailService.connect(SourceFile:4) at eu.faircode.email.ServiceSynchronize.monitorAccount(SourceFile:40) at eu.faircode.email.ServiceSynchronize.access$1100(SourceFile:1) at eu.faircode.email.ServiceSynchronize$4$2.run(SourceFile:1) at java.lang.Thread.run(Thread.java:919) Caused by: com.sun.mail.iap.BadCommandException: FY1 BAD Command Error. 10 at com.sun.mail.iap.Protocol.handleResult(SourceFile:7) at com.sun.mail.imap.protocol.IMAPProtocol.handleLoginResult(SourceFile:4) at com.sun.mail.imap.protocol.IMAPProtocol.authoauth2(SourceFile:36) at com.sun.mail.imap.IMAPStore.authenticate(SourceFile:24) at com.sun.mail.imap.IMAPStore.login(SourceFile:22) at com.sun.mail.imap.IMAPStore.protocolConnect(SourceFile:24) */ if (ex.getMessage() != null && ex.getMessage().contains("Command Error. 10")) throw new AuthenticationFailedException(context.getString(R.string.title_service_error10), ex); if (purpose == PURPOSE_CHECK) { if (port == 995 && !("pop3".equals(protocol) || "pop3s".equals(protocol))) throw new MessagingException(context.getString(R.string.title_service_port), ex); else if (ex.getMessage() != null && ex.getMessage().contains("Got bad greeting")) throw new MessagingException(context.getString(R.string.title_service_protocol), ex); else if (ex.getCause() instanceof SSLException && ex.getCause().getMessage() != null && ex.getCause().getMessage().contains("Unable to parse TLS packet header")) throw new MessagingException(context.getString(R.string.title_service_protocol), ex); else if (ex.getCause() instanceof SSLHandshakeException) throw new MessagingException(context.getString(R.string.title_service_protocol), ex); else throw ex; } else throw ex; } } private void connect( String host, int port, int auth, String user, SSLSocketFactoryService factory) throws MessagingException { Map crumb = new HashMap<>(); crumb.put("host", host); crumb.put("port", Integer.toString(port)); crumb.put("auth", Integer.toString(auth)); InetAddress main = null; boolean require_id = (purpose == PURPOSE_CHECK && auth == AUTH_TYPE_OAUTH && "outlook.office365.com".equals(host)); Log.i("Require ID=" + require_id); try { //if (BuildConfig.DEBUG) // throw new MailConnectException( // new SocketConnectException("Debug", new IOException("Test"), host, port, 0)); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); String key = "dns." + host; try { main = InetAddress.getByName(host); EntityLog.log(context, EntityLog.Type.Network, "Main address=" + main); prefs.edit().putString(key, main.getHostAddress()).apply(); } catch (UnknownHostException ex) { String last = prefs.getString(key, null); if (TextUtils.isEmpty(last)) throw ex; else { EntityLog.log(context, EntityLog.Type.Network, "Using " + key + "=" + last); main = InetAddress.getByName(last); } } boolean prefer_ip4 = prefs.getBoolean("prefer_ip4", true); if (prefer_ip4 && main instanceof Inet6Address) { boolean[] has46 = ConnectionHelper.has46(context); if (has46[0]) try { for (InetAddress iaddr : InetAddress.getAllByName(host)) if (iaddr instanceof Inet4Address) { main = iaddr; EntityLog.log(context, EntityLog.Type.Network, "Preferring=" + main); break; } } catch (UnknownHostException ex) { Log.w(ex); } } Log.breadcrumb("Connecting", crumb); _connect(main, port, require_id, user, factory); Log.breadcrumb("Connected", crumb); } catch (UnknownHostException ex) { crumb.put("exception", ex + "\n" + android.util.Log.getStackTraceString(ex)); Log.breadcrumb("Connection failed", crumb); if (ConnectionHelper.vpnActive(context)) throw new MessagingException(ex.getMessage(), new Exception(context.getString(R.string.title_service_vpn), ex)); else throw new MessagingException(ex.getMessage(), ex); } catch (MessagingException ex) { crumb.put("exception", ex + "\n" + android.util.Log.getStackTraceString(ex)); Log.breadcrumb("Connection failed", crumb); /* com.sun.mail.util.MailConnectException: Couldn't connect to host, port: 74.125.140.108, 993; timeout 20000; nested exception is: java.net.ConnectException: failed to connect to imap.gmail.com/74.125.140.108 (port 993) from /:: (port 0) after 20000ms: connect failed: EACCES (Permission denied) at com.sun.mail.imap.IMAPStore.protocolConnect(SourceFile:38) at com.sun.mail.gimap.GmailStore.protocolConnect(SourceFile:1) at javax.mail.Service.connect(SourceFile:31) at eu.faircode.email.EmailService._connect(SourceFile:29) at eu.faircode.email.EmailService.connect(SourceFile:117) at eu.faircode.email.EmailService.connect(SourceFile:38) at eu.faircode.email.EmailService.connect(SourceFile:4) at eu.faircode.email.ServiceSynchronize.monitorAccount(SourceFile:38) at eu.faircode.email.ServiceSynchronize.access$1000(SourceFile:1) at eu.faircode.email.ServiceSynchronize$4$2.run(SourceFile:1) at java.lang.Thread.run(Thread.java:923) Caused by: java.net.ConnectException: failed to connect to imap.gmail.com/74.125.140.108 (port 993) from /:: (port 0) after 20000ms: connect failed: EACCES (Permission denied) at libcore.io.IoBridge.connect(IoBridge.java:142) at java.net.PlainSocketImpl.socketConnect(PlainSocketImpl.java:142) at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:390) at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:230) at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:212) at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:436) at java.net.Socket.connect(Socket.java:621) at com.sun.mail.util.WriteTimeoutSocket.connect(SourceFile:2) at com.sun.mail.util.SocketFetcher.createSocket(SourceFile:52) at com.sun.mail.util.SocketFetcher.getSocket(SourceFile:27) at com.sun.mail.iap.Protocol.(SourceFile:10) at com.sun.mail.imap.protocol.IMAPProtocol.(SourceFile:1) at com.sun.mail.gimap.protocol.GmailProtocol.(SourceFile:1) at com.sun.mail.gimap.GmailStore.newIMAPProtocol(SourceFile:2) at com.sun.mail.imap.IMAPStore.protocolConnect(SourceFile:17) ... 10 more Caused by: android.system.ErrnoException: connect failed: EACCES (Permission denied) at libcore.io.Linux.connect(Native Method) at libcore.io.ForwardingOs.connect(ForwardingOs.java:94) at libcore.io.BlockGuardOs.connect(BlockGuardOs.java:138) at libcore.io.ForwardingOs.connect(ForwardingOs.java:94) at libcore.io.IoBridge.connectErrno(IoBridge.java:173) at libcore.io.IoBridge.connect(IoBridge.java:134) */ if (ex instanceof MailConnectException && ex.getCause() instanceof ConnectException && ex.getCause().getCause() instanceof ErrnoException && ((ErrnoException) ex.getCause().getCause()).errno == OsConstants.EACCES) throw new SecurityException("Please check 'Restrict data usage' in the Android app settings", ex); boolean ioError = false; Throwable ce = ex; while (ce != null) { if (factory != null && ce instanceof CertificateException) throw new UntrustedException(ex, factory.certificate); if (ce instanceof IOException) ioError = true; ce = ce.getCause(); } if (ioError) { EntityLog.log(context, EntityLog.Type.Network, "Connect ex=" + ex.getClass().getName() + ":" + ex + "\n" + android.util.Log.getStackTraceString(ex)); try { // Some devices resolve IPv6 addresses while not having IPv6 connectivity InetAddress[] iaddrs = InetAddress.getAllByName(host); int ip4 = (main instanceof Inet4Address ? 1 : 0); int ip6 = (main instanceof Inet6Address ? 1 : 0); boolean[] has46 = ConnectionHelper.has46(context); EntityLog.log(context, EntityLog.Type.Network, "Address main=" + main + " count=" + iaddrs.length + " ip4=" + ip4 + " max4=" + MAX_IPV4 + " has4=" + has46[0] + " ip6=" + ip6 + " max6=" + MAX_IPV6 + " has6=" + has46[1]); for (InetAddress iaddr : iaddrs) { EntityLog.log(context, EntityLog.Type.Network, "Address resolved=" + iaddr); if (iaddr.equals(main)) continue; if (iaddr instanceof Inet4Address) { if (!has46[0] || ip4 >= MAX_IPV4) continue; ip4++; } if (iaddr instanceof Inet6Address) { if (!has46[1] || ip6 >= MAX_IPV6) continue; ip6++; } try { EntityLog.log(context, EntityLog.Type.Network, "Falling back to " + iaddr); _connect(iaddr, port, require_id, user, factory); return; } catch (MessagingException ex1) { ex = ex1; EntityLog.log(context, EntityLog.Type.Network, "Fallback ex=" + ex1.getClass().getName() + ":" + ex1 + " " + android.util.Log.getStackTraceString(ex1)); } } } catch (IOException ex1) { throw new MessagingException(ex1.getMessage(), ex1); } } throw ex; } } private void _connect( InetAddress address, int port, boolean require_id, String user, SSLSocketFactoryService factory) throws MessagingException { EntityLog.log(context, EntityLog.Type.Network, "Connecting to " + address + ":" + port); isession = Session.getInstance(properties, authenticator); breadcrumbs = new RingBuffer<>(BREADCRUMBS_SIZE); boolean trace = (debug || log); isession.setDebug(trace); if (trace) isession.setDebugOut(new PrintStream(new OutputStream() { private ByteArrayOutputStream bos = new ByteArrayOutputStream(); @Override public void write(int b) { try { if (((char) b) == '\n') { String line = bos.toString(); if (!line.endsWith("ignoring socket timeout")) if (log) EntityLog.log(context, EntityLog.Type.Protocol, user + " " + line); else { breadcrumbs.push(line); if (BuildConfig.DEBUG) Log.i("javamail", user + " " + line); } bos.reset(); } else bos.write(b); } catch (Throwable ex) { Log.e(ex); } } }, true)); //System.setProperty("mail.socket.debug", Boolean.toString(debug)); isession.addProvider(new GmailSSLProvider()); if ("pop3".equals(protocol) || "pop3s".equals(protocol)) { iservice = isession.getStore(protocol); iservice.connect(address.getHostAddress(), port, user, null); } else if ("imap".equals(protocol) || "imaps".equals(protocol) || "gimaps".equals(protocol)) { iservice = isession.getStore(protocol); if (listener != null) ((IMAPStore) iservice).addStoreListener(listener); iservice.connect(address.getHostAddress(), port, user, null); // https://www.ietf.org/rfc/rfc2971.txt IMAPStore istore = (IMAPStore) getStore(); if (istore.hasCapability("ID")) try { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean client_id = prefs.getBoolean("client_id", true); Map sid = istore.id(client_id ? getId(context) : null); if (sid != null) { Map crumb = new HashMap<>(); for (String key : sid.keySet()) { crumb.put(key, sid.get(key)); EntityLog.log(context, EntityLog.Type.Protocol, "Server " + key + "=" + sid.get(key)); } Log.breadcrumb("server", crumb); } } catch (MessagingException ex) { Log.w(ex); // Check for 'User is authenticated but not connected' if (require_id) throw ex; } // Verizon if (false && istore.hasCapability("X-UIDONLY") && istore.hasCapability("ENABLE")) try { istore.enable("X-UIDONLY"); } catch (ProtocolException ex) { Log.e(ex); } } else if ("smtp".equals(protocol) || "smtps".equals(protocol)) { // https://tools.ietf.org/html/rfc5321#section-4.1.3 String hdomain = getDefaultEhlo(); String haddr = (address instanceof Inet4Address ? "[127.0.0.1]" : "[IPv6:::1]"); properties.put("mail." + protocol + ".localhost", ehlo == null ? (useip ? haddr : hdomain) : ehlo); Log.i("Using localhost=" + properties.getProperty("mail." + protocol + ".localhost")); iservice = isession.getTransport(protocol); try { iservice.connect(address.getHostAddress(), port, user, null); } catch (MessagingException ex) { if (ehlo == null && ConnectionHelper.isSyntacticallyInvalid(ex)) { properties.put("mail." + protocol + ".localhost", useip ? hdomain : haddr); Log.i("Fallback localhost=" + properties.getProperty("mail." + protocol + ".localhost")); try { iservice.connect(address.getHostAddress(), port, user, null); } catch (MessagingException ex1) { if (ConnectionHelper.isSyntacticallyInvalid(ex1)) Log.e("Used localhost=" + haddr + "/" + hdomain); throw ex1; } } else throw ex; } } else throw new NoSuchProviderException(protocol); } static Map getId(Context context) { // https://www.rfc-editor.org/rfc/rfc2971.html Map id = new LinkedHashMap<>(); id.put("name", context.getString(R.string.app_name)); id.put("version", BuildConfig.VERSION_NAME); return id; } static String getDefaultEhlo() { if (BuildConfig.APPLICATION_ID.startsWith("eu.faircode.email")) return "dummy.faircode.eu"; String[] c = BuildConfig.APPLICATION_ID.split("\\."); Collections.reverse(Arrays.asList(c)); return TextUtils.join(".", c); } List getFolders() throws MessagingException { List folders = new ArrayList<>(); for (Folder ifolder : getStore().getDefaultFolder().list("*")) { String fullName = ifolder.getFullName(); String[] attrs = ((IMAPFolder) ifolder).getAttributes(); String type = EntityFolder.getType(attrs, fullName, true); Log.i(fullName + " attrs=" + TextUtils.join(" ", attrs) + " type=" + type); if (type != null) folders.add(new EntityFolder(fullName, type)); } EntityFolder.guessTypes(folders); return folders; } Store getStore() { return (Store) iservice; } SMTPTransport getTransport() { return (SMTPTransport) iservice; } Long getMaxSize() throws MessagingException { // https://support.google.com/mail/answer/6584#limit String size; if (iservice instanceof SMTPTransport) { // https://tools.ietf.org/html/rfc1870 size = getTransport().getExtensionParameter("SIZE"); } else if (iservice instanceof IMAPStore) { // https://tools.ietf.org/html/rfc7889 size = ((IMAPStore) iservice).getCapability("APPENDLIMIT"); } else return null; if (!TextUtils.isEmpty(size) && TextUtils.isDigitsOnly(size)) { long s = Long.parseLong(size); if (s != 0) // Not infinite return s; } return null; } List getCapabilities() throws MessagingException { List result = new ArrayList<>(); Store store = getStore(); Map capabilities; if (store instanceof IMAPStore) capabilities = ((IMAPStore) getStore()).getCapabilities(); else if (store instanceof POP3Store) capabilities = ((POP3Store) getStore()).getCapabilities(); else capabilities = null; if (capabilities != null) result.addAll(capabilities.keySet()); return result; } boolean hasCapability(String capability) throws MessagingException { Store store = getStore(); if (store instanceof IMAPStore) return ((IMAPStore) getStore()).hasCapability(capability); else return false; } public Long getAccessTokenExpirationTime() { return authenticator.getAccessTokenExpirationTime(); } public boolean isOpen() { return (iservice != null && iservice.isConnected()); } public void close() throws MessagingException { try { if (iservice != null && iservice.isConnected()) iservice.close(); if (authenticator != null) authenticator = null; } finally { context = null; } } public void dump(String tag) { EntityLog.log(context, EntityLog.Type.Protocol, "Dump start " + tag); while (breadcrumbs != null && !breadcrumbs.isEmpty()) EntityLog.log(context, EntityLog.Type.Protocol, "Dump " + breadcrumbs.pop()); EntityLog.log(context, EntityLog.Type.Protocol, "Dump end " + tag); } private static class SocketFactoryService extends SocketFactory { private SocketFactory factory = SocketFactory.getDefault(); @Override public Socket createSocket() throws IOException { return configure(factory.createSocket()); } @Override public Socket createSocket(String host, int port) throws IOException, UnknownHostException { return configure(factory.createSocket(host, port)); } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return configure(factory.createSocket(host, port)); } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { return configure(factory.createSocket(host, port, localHost, localPort)); } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return configure(factory.createSocket(address, port, localAddress, localPort)); } private Socket configure(Socket socket) throws SocketException { configureSocketOptions(socket); return socket; } } private static class SSLSocketFactoryService extends SSLSocketFactory { // openssl s_client -connect host:port < /dev/null 2>/dev/null | openssl x509 -fingerprint -noout -in /dev/stdin // nmap --script ssl-enum-ciphers -Pn -p port host private String server; private boolean secure; private boolean ssl_harden; private boolean ssl_harden_strict; private boolean cert_strict; private String trustedFingerprint; private SSLSocketFactory factory; private X509Certificate certificate; SSLSocketFactoryService(String host, boolean insecure, boolean ssl_harden, boolean ssl_harden_strict, boolean cert_strict, boolean bc, boolean fips, PrivateKey key, X509Certificate[] chain, String fingerprint) throws GeneralSecurityException { this.server = host; this.secure = !insecure; this.ssl_harden = ssl_harden; this.ssl_harden_strict = ssl_harden_strict; this.cert_strict = cert_strict; this.trustedFingerprint = fingerprint; TrustManager[] tms = SSLHelper.getTrustManagers(server, secure, cert_strict, trustedFingerprint, new SSLHelper.ITrust() { @Override public void checkServerTrusted(X509Certificate[] chain) { certificate = chain[0]; } }); KeyManager[] km = null; if (key != null && chain != null) try { Log.i("Client certificate init"); KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, new char[0]); ks.setKeyEntry(server, key, new char[0], chain); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); kmf.init(ks, new char[0]); km = kmf.getKeyManagers(); Log.i("Client certificate initialized"); } catch (Throwable ex) { Log.e(ex); } // https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#SSLContext // https://stackoverflow.com/questions/69571364/sslcontext-getinstancetls-vulnerability // https://developer.android.com/about/versions/oreo/android-8.0-changes.html#security-all SSLContext sslContext; String protocol = (insecure ? "SSL" : "TLS"); if (bc) sslContext = SSLContext.getInstance(protocol, new BouncyCastleJsseProvider(fips)); else sslContext = SSLContext.getInstance(protocol); Log.i("Using protocol=" + protocol + " bc=" + bc + " FIPS=" + fips); sslContext.init(km, tms, null); factory = sslContext.getSocketFactory(); } @Override public Socket createSocket() throws IOException { Log.e("createSocket"); throw new IOException("createSocket"); } @Override public Socket createSocket(String host, int port) throws IOException { ApplicationSecure.waitProviderInstalled(); return configure(factory.createSocket(server, port)); } @Override public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { configureSocketOptions(s); ApplicationSecure.waitProviderInstalled(); return configure(factory.createSocket(s, server, port, autoClose)); } @Override public Socket createSocket(InetAddress address, int port) throws IOException { Log.e("createSocket(address, port)"); throw new IOException("createSocket"); } @Override public Socket createSocket(String host, int port, InetAddress clientAddress, int clientPort) throws IOException { ApplicationSecure.waitProviderInstalled(); return configure(factory.createSocket(server, port, clientAddress, clientPort)); } @Override public Socket createSocket(InetAddress address, int port, InetAddress clientAddress, int clientPort) throws IOException { Log.e("createSocket(address, port, clientAddress, clientPort)"); throw new IOException("createSocket"); } private Socket configure(Socket socket) throws SocketException { if (socket instanceof SSLSocket) { SSLSocket sslSocket = (SSLSocket) socket; if (!secure) { Log.i("SSL insecure"); // Protocols sslSocket.setEnabledProtocols(sslSocket.getSupportedProtocols()); // Ciphers List ciphers = new ArrayList<>(); ciphers.addAll(Arrays.asList(sslSocket.getSupportedCipherSuites())); ciphers.remove("TLS_FALLBACK_SCSV"); sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); } else if (ssl_harden && ssl_harden_strict && !BuildConfig.PLAY_STORE_RELEASE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Log.i("SSL harden strict"); // Protocols List protocols = new ArrayList<>(); for (String protocol : sslSocket.getSupportedProtocols()) if (SSL_PROTOCOL_BLACKLIST_STRICT.contains(protocol)) Log.i("SSL disabling protocol=" + protocol); else protocols.add(protocol); sslSocket.setEnabledProtocols(protocols.toArray(new String[0])); // Ciphers List ciphers = new ArrayList<>(); for (String cipher : sslSocket.getEnabledCipherSuites()) { if (SSL_CIPHER_BLACKLIST_STRICT.matcher(cipher).matches()) Log.i("SSL disabling cipher=" + cipher); else ciphers.add(cipher); } sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); } else if (ssl_harden) { Log.i("SSL harden"); // Protocols List protocols = new ArrayList<>(); for (String protocol : sslSocket.getSupportedProtocols()) if (SSL_PROTOCOL_BLACKLIST.contains(protocol)) Log.i("SSL disabling protocol=" + protocol); else protocols.add(protocol); sslSocket.setEnabledProtocols(protocols.toArray(new String[0])); // Ciphers List ciphers = new ArrayList<>(); for (String cipher : sslSocket.getEnabledCipherSuites()) { if (SSL_CIPHER_BLACKLIST.matcher(cipher).matches()) Log.i("SSL disabling cipher=" + cipher); else ciphers.add(cipher); } sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); } else { Log.i("SSL default"); // Protocols List protocols = new ArrayList<>(); for (String protocol : sslSocket.getSupportedProtocols()) if (SSL_PROTOCOL_INSECURE.contains(protocol)) Log.i("SSL disabling protocol=" + protocol); else protocols.add(protocol); sslSocket.setEnabledProtocols(protocols.toArray(new String[0])); // Ciphers List ciphers = new ArrayList<>(); ciphers.addAll(Arrays.asList(sslSocket.getEnabledCipherSuites())); for (String cipher : sslSocket.getSupportedCipherSuites()) if (!ciphers.contains(cipher) && cipher.contains("3DES")) { // Some servers support 3DES and RC4 only Log.i("SSL enabling cipher=" + cipher); ciphers.add(cipher); } sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); } Log.i("SSL protocols=" + TextUtils.join(",", sslSocket.getEnabledProtocols())); Log.i("SSL ciphers=" + TextUtils.join(",", sslSocket.getEnabledCipherSuites())); } return socket; } @Override public String[] getDefaultCipherSuites() { return factory.getDefaultCipherSuites(); } @Override public String[] getSupportedCipherSuites() { return factory.getSupportedCipherSuites(); } } private static void configureSocketOptions(Socket socket) throws SocketException { int timeout = socket.getSoTimeout(); boolean keepAlive = socket.getKeepAlive(); int linger = socket.getSoLinger(); boolean reuse = socket.getReuseAddress(); boolean delay = socket.getTcpNoDelay(); Log.i("Socket type=" + socket.getClass().getName() + " timeout=" + timeout + " keep-alive=" + keepAlive + " linger=" + linger + " reuse=" + reuse + " delay=" + delay); if (keepAlive) { Log.e("Socket keep-alive=" + keepAlive); socket.setKeepAlive(false); // sets SOL_SOCKET/SO_KEEPALIVE } if (linger >= 0) { Log.e("Socket linger=" + linger); socket.setSoLinger(false, -1); } if (reuse) { Log.e("Socket reuse=" + reuse); socket.setReuseAddress(false); } if (delay) { Log.e("Socket delay=" + delay); socket.setTcpNoDelay(false); } try { boolean tcp_keep_alive = Boolean.parseBoolean(System.getProperty("fairemail.tcp_keep_alive")); if (tcp_keep_alive) { Log.i("Enabling TCP keep alive"); int fd = ParcelFileDescriptor.fromSocket(socket).getFd(); int errno = ConnectionHelper.jni_socket_keep_alive(fd, TCP_KEEP_ALIVE_INTERVAL); if (errno == 0) Log.i("Enabled TCP keep alive"); else throw new ErrnoException("jni_socket_keep_alive", errno); } } catch (Throwable ex) { Log.e(ex); } } static String getEncryptionName(int type) { switch (type) { case ENCRYPTION_SSL: return "ssl"; case ENCRYPTION_STARTTLS: return "starttls"; case ENCRYPTION_NONE: return "none"; default: return Integer.toString(type); } } static class UntrustedException extends MessagingException { private X509Certificate certificate; UntrustedException(@NonNull Exception cause, @NonNull X509Certificate certificate) { super("Untrusted", cause); this.certificate = certificate; } X509Certificate getCertificate() { return certificate; } @NonNull @Override public synchronized String toString() { return getCause().toString(); } } }