/* * Kontalk Android client * Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org> * This program 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. * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.service; import java.io.IOException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.TimeUnit; import com.segment.backo.Backo; import org.jivesoftware.smack.AbstractXMPPConnection; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.SmackException.NotConnectedException; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.sasl.SASLError; import org.jivesoftware.smack.sasl.SASLErrorException; import org.spongycastle.openpgp.PGPException; import android.content.Context; import android.provider.Settings; import org.kontalk.Kontalk; import org.kontalk.Log; import org.kontalk.authenticator.LegacyAuthentication; import org.kontalk.client.EndpointServer; import org.kontalk.client.KontalkConnection; import org.kontalk.crypto.PGP; import org.kontalk.crypto.PersonalKey; import org.kontalk.crypto.X509Bridge; import org.kontalk.service.msgcenter.MessageCenterService; import org.kontalk.service.msgcenter.PGPKeyPairRingProvider; import org.kontalk.util.InternalTrustStore; import org.kontalk.util.Preferences; /** * XMPP connection helper. * @author Daniele Ricci */ public class XMPPConnectionHelper extends Thread { private static final String TAG = MessageCenterService.TAG; /** Whether to use STARTTLS or direct SSL connection. */ private static final boolean USE_STARTTLS = true; /** Max connection retry count if idle. */ private static final int MAX_IDLE_BACKOFF = 10; /** Max retries after for authentication error. */ private static final int MAX_AUTH_ERRORS = 3; private final Context mContext; private EndpointServer mServer; private boolean mServerDirty; /** Connection retry count for exponential backoff. */ private int mRetryCount; /** Exponential backoff calculator. */ private final Backo mRetryBackoff; /** Connection is re-created on demand if necessary. */ protected KontalkConnection mConn; /** Client listener. */ private ConnectionHelperListener mListener; /** Limited connection flag. */ protected boolean mLimited; /** Retry enabled flag. */ protected boolean mRetryEnabled = true; /** Waiting for exponential backoff. */ protected boolean mBackoff; /** Connecting flag. */ protected volatile boolean mConnecting; /** * Creates a new instance. * @param context * @param server server to connect to. * @param limited if true connection will be carried out even when there is * no personal key; connection will be available for unauthenticated * operations only (e.g. registration). */ public XMPPConnectionHelper(Context context, EndpointServer server, boolean limited) { super("XMPPConnector"); mContext = context; mServer = server; mLimited = limited; mRetryBackoff = Backo.builder() .base(TimeUnit.MILLISECONDS, 1500) .cap(TimeUnit.SECONDS, 300) .factor(2) .jitter(1) .build(); } public void setListener(ConnectionHelperListener listener) { mListener = listener; } public void setRetryEnabled(boolean enabled) { mRetryEnabled = enabled; } @Override public synchronized void start() { mConnecting = true; super.start(); } public void run() { connect(); } public void connectOnce(PersonalKey key, boolean forceLogin) throws XMPPException, SmackException, PGPException, KeyStoreException, NoSuchProviderException, NoSuchAlgorithmException, CertificateException, IOException { connectOnce(key, null, forceLogin); } private void connectOnce(PersonalKey key, String token, boolean forceLogin) throws XMPPException, SmackException, PGPException, IOException, KeyStoreException, NoSuchProviderException, NoSuchAlgorithmException, CertificateException { Log.d(TAG, "using server " + mServer.toString()); if (mServerDirty) { // reset dirty server status mServerDirty = false; // destroy connection if (mConn != null) { mConn.instantShutdown(); mConn = null; } } // recreate connection if closed if (mConn == null || !mConn.isConnected()) { KeyStore trustStore = null; boolean acceptAnyCertificate = Preferences.getAcceptAnyCertificate(mContext); if (!acceptAnyCertificate) trustStore = InternalTrustStore.getTrustStore(mContext); String resource = getResource(mContext); if (key == null) { mConn = new KontalkConnection(resource, mServer, !USE_STARTTLS, acceptAnyCertificate, trustStore, token); } else { mConn = new KontalkConnection(resource, mServer, !USE_STARTTLS, key.getBridgePrivateKey(), key.getBridgeCertificate(), acceptAnyCertificate, trustStore, token); } // apply packet timeout based on retry count mConn.setPacketReplyTimeout((mRetryCount + 1) * KontalkConnection.DEFAULT_PACKET_TIMEOUT); if (mListener != null) mListener.created(mConn); } // connect mConn.connect(); if (mListener != null) { mConn.addConnectionListener(mListener); mListener.connected(mConn); } // login if ((!mLimited || forceLogin) && (key != null || token != null)) mConn.login(); } public void connect() { PersonalKey key = null; if (LegacyAuthentication.isUpgrading() && mListener != null) { PGPKeyPairRingProvider keyProv = mListener.getKeyPairRingProvider(); if (keyProv != null) { PGP.PGPKeyPairRing keyring = keyProv.getKeyPair(); if (keyring != null) { String passphrase = ((Kontalk) mContext.getApplicationContext()).getCachedPassphrase(); try { X509Certificate bridgeCert = X509Bridge.createCertificate(keyring.publicKey, keyring.secretKey.getSecretKey(), passphrase); key = PersonalKey.load(keyring.secretKey, keyring.publicKey, passphrase, bridgeCert); } catch (Exception e) { // this will go crap... Log.e(TAG, "unable to create temporary personal key - not using SSL", e); } } } } if (key == null) { try { key = ((Kontalk) mContext.getApplicationContext()).getPersonalKey(); } catch (Exception e) { Log.e(TAG, "unable to retrieve personal key - not using SSL", e); } } String token = LegacyAuthentication.getAuthToken(mContext); if (key == null && token == null && !mLimited) { Log.w(TAG, "no personal key found - exiting"); // unrecoverable error if (mListener != null) mListener.aborted(null); return; } while (mConnecting) { try { connectOnce(key, token, false); // this should be the right moment mRetryCount = 0; // all done! break; } catch (Exception ie) { // uncontrolled interrupt - handle errors if (mConnecting) { Log.e(TAG, "connection error", ie); if (mConn != null) { // forcibly close connection, no matter what mConn.instantShutdown(); // EXTERMINATE!! mConn = null; } // SASL: not authorized if (ie instanceof SASLErrorException) { SASLError error = ((SASLErrorException) ie).getSASLFailure().getSASLError(); if ((error == SASLError.not_authorized || error == SASLError.invalid_authzid) && mRetryCount >= MAX_AUTH_ERRORS) { if (mListener != null) { mListener.authenticationFailed(); // this ends here. break; } } } if (mRetryEnabled) { try { // max reconnections - idle message center if (mRetryCount >= MAX_IDLE_BACKOFF) { Log.d(TAG, "maximum number of reconnections - stopping message center"); if (mListener != null) mListener.aborted(ie); // no need to continue break; } // exponential backoff :) long time = mRetryBackoff.backoff(++mRetryCount); Log.d(TAG, "retrying in " + (time/1000) + " seconds (retry="+mRetryCount+")"); // notify listener we are reconnecting if (mListener != null) mListener.reconnectingIn((int) time); mBackoff = true; Thread.sleep(time); // this is to avoid the exponential backoff counter to be reset continue; } catch (InterruptedException intexc) { // interrupted - exit Log.e(TAG, "- interrupted."); break; } finally { mBackoff = false; } } else { // retry disabled - notify and exit if (mListener != null) mListener.connectionClosedOnError(ie); break; } } } mRetryCount = 0; } mConnecting = false; } private static String getResource(Context context) { return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); } public AbstractXMPPConnection getConnection() { return mConn; } public boolean isConnected() { return (mConn != null && mConn.isAuthenticated()); } public boolean isConnecting() { return mConnecting; } public boolean isStruggling() { return mConnecting && mRetryCount > 5; } public boolean isServerDirty() { return mServerDirty; } public boolean isBackingOff() { return mBackoff; } /** Shortcut for {@link EndpointServer#getNetwork()}. */ public String getNetwork() { return mServer.getNetwork(); } public EndpointServer getServer() { return mServer; } /** Sets the server the next time we will connect to. */ public void setServer(EndpointServer server) { mServer = server; mServerDirty = true; } /** Shuts down this client thread gracefully. */ public void shutdown() throws NotConnectedException { mConnecting = false; interrupt(); if (mConn != null) mConn.instantShutdown(); } public interface ConnectionHelperListener extends ConnectionListener { /** Connection has been created. */ public void created(XMPPConnection connection); /** Connection was aborted and will never be tried again. */ public void aborted(Exception e); public void authenticationFailed(); public PGPKeyPairRingProvider getKeyPairRingProvider(); } }