/** * Copyright (c) 2013, Redsolution LTD. All rights reserved. * * This file is part of Xabber project; you can redistribute it and/or * modify it under the terms of the GNU General Public License, Version 3. * * Xabber 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 com.xabber.android.data.extension.otr; import android.database.Cursor; import android.support.annotation.Nullable; import com.xabber.android.BuildConfig; import com.xabber.android.R; import com.xabber.android.data.Application; import com.xabber.android.data.log.LogManager; import com.xabber.android.data.NetworkException; import com.xabber.android.data.OnCloseListener; import com.xabber.android.data.OnLoadListener; import com.xabber.android.data.SettingsManager; import com.xabber.android.data.SettingsManager.SecurityOtrMode; import com.xabber.android.data.account.AccountItem; import com.xabber.android.data.account.AccountManager; import com.xabber.android.data.account.listeners.OnAccountAddedListener; import com.xabber.android.data.account.listeners.OnAccountRemovedListener; import com.xabber.android.data.connection.StanzaSender; import com.xabber.android.data.database.sqlite.OTRTable; import com.xabber.android.data.entity.AccountJid; import com.xabber.android.data.entity.NestedMap; import com.xabber.android.data.entity.NestedMap.Entry; import com.xabber.android.data.entity.NestedNestedMaps; import com.xabber.android.data.entity.UserJid; import com.xabber.android.data.extension.ssn.SSNManager; import com.xabber.android.data.message.AbstractChat; import com.xabber.android.data.message.ChatAction; import com.xabber.android.data.message.MessageManager; import com.xabber.android.data.notification.EntityNotificationProvider; import com.xabber.android.data.notification.NotificationManager; import com.xabber.android.data.roster.RosterManager; import com.xabber.xmpp.archive.OtrMode; import net.java.otr4j.OtrEngineHost; import net.java.otr4j.OtrEngineListener; import net.java.otr4j.OtrException; import net.java.otr4j.OtrPolicy; import net.java.otr4j.OtrPolicyImpl; import net.java.otr4j.crypto.OtrCryptoEngine; import net.java.otr4j.crypto.OtrCryptoEngineImpl; import net.java.otr4j.crypto.OtrCryptoException; import net.java.otr4j.io.SerializationUtils; import net.java.otr4j.session.FragmenterInstructions; import net.java.otr4j.session.InstanceTag; import net.java.otr4j.session.Session; import net.java.otr4j.session.SessionID; import net.java.otr4j.session.SessionImpl; import net.java.otr4j.session.SessionStatus; import org.jxmpp.stringprep.XmppStringprepException; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; /** * Manage off-the-record encryption. * <p/> * http://www.cypherpunks.ca/otr/ * * @author alexander.ivanov */ public class OTRManager implements OtrEngineHost, OtrEngineListener, OnLoadListener, OnAccountAddedListener, OnAccountRemovedListener, OnCloseListener { private static OTRManager instance; private static Map<SecurityOtrMode, OtrPolicy> POLICIES; static { POLICIES = new HashMap<>(); POLICIES.put(SecurityOtrMode.disabled, new OtrPolicyImpl(OtrPolicy.NEVER)); POLICIES.put(SecurityOtrMode.manual, new OtrPolicyImpl(OtrPolicy.OTRL_POLICY_MANUAL & ~OtrPolicy.ALLOW_V1)); POLICIES.put(SecurityOtrMode.auto, new OtrPolicyImpl(OtrPolicy.OPPORTUNISTIC & ~OtrPolicy.ALLOW_V1)); POLICIES.put(SecurityOtrMode.required, new OtrPolicyImpl(OtrPolicy.OTRL_POLICY_ALWAYS & ~OtrPolicy.ALLOW_V1)); } private final EntityNotificationProvider<SMRequest> smRequestProvider; private final EntityNotificationProvider<SMProgress> smProgressProvider; /** * Accepted fingerprints for user in account. */ private final NestedNestedMaps<String, Boolean> fingerprints; /** * Fingerprint of encrypted or encrypted and verified session for user in account. */ private final NestedMap<String> actives; /** * Finished entity's sessions for users in accounts. */ private final NestedMap<Boolean> finished; /** * Used OTR sessions for users in accounts. */ private final NestedMap<Session> sessions; /** * Service for keypair generation. */ private final ExecutorService keyPairGenerator; public static OTRManager getInstance() { if (instance == null) { instance = new OTRManager(); } return instance; } private OTRManager() { smRequestProvider = new EntityNotificationProvider<>(R.drawable.ic_stat_help); smProgressProvider = new EntityNotificationProvider<>(R.drawable.ic_stat_play_circle_fill); smProgressProvider.setCanClearNotifications(false); fingerprints = new NestedNestedMaps<>(); actives = new NestedMap<>(); finished = new NestedMap<>(); sessions = new NestedMap<>(); keyPairGenerator = Executors.newSingleThreadExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable runnable) { Thread thread = new Thread(runnable, "Key pair generator service"); thread.setPriority(Thread.MIN_PRIORITY); thread.setDaemon(true); return thread; } }); } @Override public void onLoad() { final NestedNestedMaps<String, Boolean> fingerprints = new NestedNestedMaps<>(); Cursor cursor = OTRTable.getInstance().list(); try { if (cursor.moveToFirst()) { do { String account = OTRTable.getAccount(cursor); String user = OTRTable.getUser(cursor); fingerprints.put(account, user, OTRTable.getFingerprint(cursor), OTRTable.isVerified(cursor)); } while (cursor.moveToNext()); } } finally { cursor.close(); } Application.getInstance().runOnUiThread(new Runnable() { @Override public void run() { onLoaded(fingerprints); } }); } private void onLoaded(NestedNestedMaps<String, Boolean> fingerprints) { this.fingerprints.addAll(fingerprints); NotificationManager.getInstance().registerNotificationProvider(smRequestProvider); NotificationManager.getInstance().registerNotificationProvider(smProgressProvider); } public void startSession(AccountJid account, UserJid user) throws NetworkException { LogManager.i(this, "Starting session for " + user); try { getOrCreateSession(account.toString(), user.toString()).startSession(); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } LogManager.i(this, "Started session for " + user); } public void refreshSession(AccountJid account, UserJid user) throws NetworkException { LogManager.i(this, "Refreshing session for " + user); try { getOrCreateSession(account.toString(), user.toString()).refreshSession(); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } LogManager.i(this, "Refreshed session for " + user); } private void endSession(String account, String user) throws NetworkException { LogManager.i(this, "Ending session for " + user); try { getOrCreateSession(account, user).endSession(); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } AbstractChat abstractChat = getChat(account, user); if (abstractChat != null) { SSNManager.getInstance().setSessionOtrMode(account, user, abstractChat.getThreadId(), OtrMode.concede); LogManager.i(this, "Ended session for " + user); } } @Nullable private AbstractChat getChat(String account, String user) { try { return MessageManager.getInstance().getChat(AccountJid.from(account), UserJid.from(user)); } catch (UserJid.UserJidCreateException | XmppStringprepException e) { LogManager.exception(this, e); return null; } } public void endSession(AccountJid account, UserJid user) throws NetworkException { endSession(account.toString(), user.toString()); } private Session getOrCreateSession(String account, String user) { Session session = sessions.get(account, user); if (session != null) { LogManager.i(this, "Found session with id " + session.getSessionID() + " with status " + session.getSessionStatus() + " for user " + user); return session; } LogManager.i(this, "Creating new session for " + user); session = new SessionImpl(new SessionID(account, user, "xmpp"), this); session.addOtrEngineListener(this); sessions.put(account, user, session); return session; } @Override public void injectMessage(SessionID sessionID, String msg) throws OtrException { injectMessage(sessionID.getAccountID(), sessionID.getUserID(), msg); } private void injectMessage(String account, String user, String msg) throws OtrException { LogManager.i(this, "injectMessage. user: " + user + " message: " + msg); AbstractChat abstractChat = getChat(account, user); SSNManager.getInstance().setSessionOtrMode(account, user, abstractChat.getThreadId(), OtrMode.prefer); try { StanzaSender.sendStanza(abstractChat.getAccount(), abstractChat.createMessagePacket(msg)); } catch (NetworkException e) { throw new OtrException(e); } } @Override public void unreadableMessageReceived(SessionID sessionID) throws OtrException { LogManager.i(this, "unreadableMessageReceived"); newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_unreadable); } /** * Creates new action in specified chat. */ private void newAction(String account, String user, String text, ChatAction action) { LogManager.i(this, "newAction. text: " + text + " action " + action); AbstractChat chat = getChat(account, user); if (chat != null) { chat.newAction(null, text, action); } } @Override public String getReplyForUnreadableMessage(SessionID sessionID) { return Application.getInstance().getString(R.string.otr_unreadable_message); } @Override public void unencryptedMessageReceived(SessionID sessionID, String msg) throws OtrException { LogManager.i(this, "unencrypted Message Received. " + msg); throw new OtrException(new OTRUnencryptedException(msg)); } @Override public void showError(SessionID sessionID, String error) throws OtrException { LogManager.i(this, "ShowError: " + error); newAction(sessionID.getAccountID(), sessionID.getUserID(), error, ChatAction.otr_error); } @Override public void smpError(SessionID sessionID, int tlvType, boolean cheated) throws OtrException { newAction(sessionID.getAccountID(), sessionID.getUserID(), null, cheated ? ChatAction.otr_smp_cheated : ChatAction.otr_smp_failed); if (cheated) { removeSMProgress(sessionID.getAccountID(), sessionID.getUserID()); } } @Override public void smpAborted(SessionID sessionID) throws OtrException { removeSMRequest(sessionID.getAccountID(), sessionID.getUserID()); removeSMProgress(sessionID.getAccountID(), sessionID.getUserID()); } @Override public void finishedSessionMessage(SessionID sessionID, String msgText) throws OtrException { newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_finished_session); throw new OtrException(new IllegalStateException( "Prevent from null to be returned. Just process it as regular exception.")); } @Override public void requireEncryptedMessage(SessionID sessionID, String msgText) throws OtrException { throw new OtrException(new IllegalStateException( "Prevent from null to be returned. Just process it as regular exception.")); } @Override public OtrPolicy getSessionPolicy(SessionID sessionID) { return POLICIES.get(SettingsManager.securityOtrMode()); } @Override public FragmenterInstructions getFragmenterInstructions(SessionID sessionID) { return null; } private KeyPair getLocalKeyPair(String account) throws OtrException { KeyPair keyPair = null; try { AccountItem accountItem = AccountManager.getInstance().getAccount(AccountJid.from(account)); if (accountItem != null) { keyPair = accountItem.getKeyPair(); } } catch (XmppStringprepException e) { LogManager.exception(this, e); } if (keyPair == null) { throw new OtrException(new IllegalStateException("KeyPair is not ready, yet.")); } return keyPair; } @Override public KeyPair getLocalKeyPair(SessionID sessionID) throws OtrException { return getLocalKeyPair(sessionID.getAccountID()); } @Override public void sessionStatusChanged(SessionID sessionID) { removeSMRequest(sessionID.getAccountID(), sessionID.getUserID()); removeSMProgress(sessionID.getAccountID(), sessionID.getUserID()); Session session = sessions.get(sessionID.getAccountID(), sessionID.getUserID()); SessionStatus sStatus = session.getSessionStatus(); LogManager.i(this, "session status changed " + sessionID.getUserID() + " status: " + sStatus); if (sStatus == SessionStatus.ENCRYPTED) { finished.remove(sessionID.getAccountID(), sessionID.getUserID()); PublicKey remotePublicKey = session.getRemotePublicKey(); String value; try { OtrCryptoEngine otrCryptoEngine = new OtrCryptoEngineImpl(); value = otrCryptoEngine.getFingerprint(remotePublicKey); } catch (OtrCryptoException e) { LogManager.exception(this, e); value = null; } if (value != null) { actives.put(sessionID.getAccountID(), sessionID.getUserID(), value); if (fingerprints.get(sessionID.getAccountID(), sessionID.getUserID(), value) == null) { fingerprints.put(sessionID.getAccountID(), sessionID.getUserID(), value, false); requestToWrite(sessionID.getAccountID(), sessionID.getUserID(), value, false); } } newAction(sessionID.getAccountID(), sessionID.getUserID(), null, isVerified(sessionID.getAccountID(), sessionID.getUserID()) ? ChatAction.otr_verified : ChatAction.otr_encryption); AbstractChat chat = getChat(sessionID.getAccountID(), sessionID.getUserID()); if (chat != null) { chat.sendMessages(); } } else if (sStatus == SessionStatus.PLAINTEXT) { actives.remove(sessionID.getAccountID(), sessionID.getUserID()); sessions.remove(sessionID.getAccountID(), sessionID.getUserID()); finished.remove(sessionID.getAccountID(), sessionID.getUserID()); try { session.endSession(); } catch (OtrException e) { LogManager.exception(this, e); } newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_plain); } else if (sStatus == SessionStatus.FINISHED) { actives.remove(sessionID.getAccountID(), sessionID.getUserID()); sessions.remove(sessionID.getAccountID(), sessionID.getUserID()); finished.put(sessionID.getAccountID(), sessionID.getUserID(), true); newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_finish); } else { throw new IllegalStateException(); } onContactChanged(sessionID); } public void onContactChanged(SessionID sessionID) { try { RosterManager.onContactChanged(AccountJid.from(sessionID.getAccountID()), UserJid.from(sessionID.getUserID())); } catch (UserJid.UserJidCreateException | XmppStringprepException e) { LogManager.exception(this, e); } } @Override public void askForSecret(SessionID sessionID, InstanceTag receiverTag, String question) { try { smRequestProvider.add(new SMRequest(AccountJid.from(sessionID.getAccountID()), UserJid.from(sessionID.getUserID()), question), true); } catch (UserJid.UserJidCreateException | XmppStringprepException e) { LogManager.exception(this, e); } } /** * Transform outgoing message before sending. */ public String transformSending(AccountJid account, UserJid user, String content) throws OtrException { LogManager.i(this, "transform outgoing message... " + user); String parts[] = getOrCreateSession(account.toString(), user.toString()).transformSending(content, null); if (BuildConfig.DEBUG && parts.length != 1) { throw new RuntimeException( "We do not use fragmentation, so there must be only one otr fragment."); } return parts[0]; } /** * Transform incoming message after receiving. */ public String transformReceiving(AccountJid account, UserJid user, String content) throws OtrException { LogManager.i(this, "transform incoming message... " + content); Session session = getOrCreateSession(account.toString(), user.toString()); try { String s = session.transformReceiving(content); LogManager.i(this, "transformed incoming message: " + s + " session status: " + session.getSessionStatus()); return s; } catch (UnsupportedOperationException e) { throw new OtrException(e); } } public SecurityLevel getSecurityLevel(AccountJid account, UserJid user) { if (actives.get(account.toString(), user.toString()) == null) { if (finished.get(account.toString(), user.toString()) == null) { return SecurityLevel.plain; } else { return SecurityLevel.finished; } } else { if (isVerified(account, user)) { return SecurityLevel.verified; } else { return SecurityLevel.encrypted; } } } public boolean isVerified(AccountJid account, UserJid user) { return isVerified(account.toString(), user.toString()); } private boolean isVerified(String account, String user) { String active = actives.get(account, user); if (active == null) { return false; } Boolean value = fingerprints.get(account, user, active); return value != null && value; } private void setVerifyWithoutNotification(String account, String user, String fingerprint, boolean value) { fingerprints.put(account, user, fingerprint, value); requestToWrite(account, user, fingerprint, value); } /** * Set whether fingerprint was verified. Add action to the chat history. */ public void setVerify(AccountJid account, UserJid user, String fingerprint, boolean value) { setVerifyWithoutNotification(account.toString(), user.toString(), fingerprint, value); if (value) { newAction(account.toString(), user.toString(), null, ChatAction.otr_smp_verified); } else if (actives.get(account.toString(), user.toString()) != null) { newAction(account.toString(), user.toString(), null, ChatAction.otr_encryption); } } private void setVerify(SessionID sessionID, boolean value) { String active = actives.get(sessionID.getAccountID(), sessionID.getUserID()); if (active == null) { LogManager.exception(this, new IllegalStateException("There is no active fingerprint")); return; } setVerifyWithoutNotification(sessionID.getAccountID(), sessionID.getUserID(), active, value); newAction(sessionID.getAccountID(), sessionID.getUserID(), null, value ? ChatAction.otr_smp_verified : ChatAction.otr_smp_unverified); onContactChanged(sessionID); } @Override public void verify(SessionID sessionID, String fingerprint, boolean approved) { if (approved) { setVerify(sessionID, true); } else if (isVerified(sessionID.getAccountID(), sessionID.getUserID())) { newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_smp_not_approved); } removeSMProgress(sessionID.getAccountID(), sessionID.getUserID()); } @Override public void unverify(SessionID sessionID, String fingerprint) { setVerify(sessionID, false); removeSMProgress(sessionID.getAccountID(), sessionID.getUserID()); } public String getRemoteFingerprint(AccountJid account, UserJid user) { return actives.get(account.toString(), user.toString()); } @Nullable public String getLocalFingerprint(AccountJid account) { try { OtrCryptoEngine otrCryptoEngine = new OtrCryptoEngineImpl(); return otrCryptoEngine.getFingerprint(getLocalKeyPair(account.toString()).getPublic()); } catch (OtrException e) { LogManager.exception(this, e); } return null; } @Nullable @Override public byte[] getLocalFingerprintRaw(SessionID sessionID) { try { return SerializationUtils.hexStringToByteArray(getLocalFingerprint(AccountJid.from(sessionID.getAccountID()))); } catch (XmppStringprepException e) { LogManager.exception(this, e); return null; } } @Override public String getFallbackMessage(SessionID sessionID) { return Application.getInstance().getString(R.string.otr_request); } /** * Respond using SM protocol. */ public void respondSmp(AccountJid account, UserJid user, String question, String secret) throws NetworkException { LogManager.i(this, "responding smp... " + user); removeSMRequest(account, user); addSMProgress(account, user); try { getOrCreateSession(account.toString(), user.toString()).respondSmp(question, secret); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } } /** * Initiate request using SM protocol. */ public void initSmp(AccountJid account, UserJid user, String question, String secret) throws NetworkException { LogManager.i(this, "initializing smp... " + user); removeSMRequest(account.toString(), user.toString()); addSMProgress(account, user); try { getOrCreateSession(account.toString(), user.toString()).initSmp(question, secret); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } } /** * Abort SM negotiation. */ public void abortSmp(AccountJid account, UserJid user) throws NetworkException { LogManager.i(this, "aborting smp... " + user); removeSMRequest(account.toString(), user.toString()); removeSMProgress(account.toString(), user.toString()); try { getOrCreateSession(account.toString(), user.toString()).abortSmp(); } catch (OtrException e) { throw new NetworkException(R.string.OTR_ERROR, e); } } private void removeSMRequest(AccountJid account, UserJid user) { smRequestProvider.remove(account, user); } private void removeSMRequest(String account, String user) { try { smRequestProvider.remove(AccountJid.from(account), UserJid.from(user)); } catch (UserJid.UserJidCreateException | XmppStringprepException e) { LogManager.exception(this, e); } } private void addSMProgress(AccountJid account, UserJid user) { smProgressProvider.add(new SMProgress(account, user), false); } private void removeSMProgress(String account, String user) { try { smProgressProvider.remove(AccountJid.from(account), UserJid.from(user)); } catch (UserJid.UserJidCreateException | XmppStringprepException e) { LogManager.exception(this, e); } } @Override public void onAccountAdded(final AccountItem accountItem) { if (accountItem.getKeyPair() != null) { return; } keyPairGenerator.execute(new Runnable() { @Override public void run() { LogManager.i(this, "KeyPair generation started for " + accountItem.getAccount()); final KeyPair keyPair; try { keyPair = KeyPairGenerator.getInstance("DSA").genKeyPair(); } catch (final NoSuchAlgorithmException e) { Application.getInstance().runOnUiThread(new Runnable() { @Override public void run() { throw new RuntimeException(e); } }); return; } Application.getInstance().runOnUiThread(new Runnable() { @Override public void run() { LogManager.i(this, "KeyPair generation finished for " + accountItem.getAccount()); if (AccountManager.getInstance().getAccount(accountItem.getAccount()) != null) { AccountManager.getInstance().setKeyPair(accountItem.getAccount(), keyPair); } } }); } }); } @Override public void onAccountRemoved(AccountItem accountItem) { fingerprints.clear(accountItem.getAccount().toString()); actives.clear(accountItem.getAccount().toString()); finished.clear(accountItem.getAccount().toString()); sessions.clear(accountItem.getAccount().toString()); } /** * Save chat specific otr settings. */ private void requestToWrite(final String account, final String user, final String fingerprint, final boolean verified) { Application.getInstance().runInBackgroundUserRequest(new Runnable() { @Override public void run() { OTRTable.getInstance().write(account, user, fingerprint, verified); } }); } private void endAllSessions() { LogManager.i(this, "End all sessions"); NestedMap<String> entities = new NestedMap<>(); entities.addAll(actives); for (Entry<String> entry : entities) { try { endSession(entry.getFirst(), entry.getSecond()); } catch (NetworkException e) { LogManager.exception(this, e); } } } @Override public void onClose() { endAllSessions(); } public void onSettingsChanged() { if (SettingsManager.securityOtrMode() == SecurityOtrMode.disabled) { endAllSessions(); } } @Override public void outgoingSessionChanged(SessionID sessionID) { LogManager.i(this, "Outgoing session change with SessionID " + sessionID); // TODO what to in this situation? } @Override public void messageFromAnotherInstanceReceived(SessionID sessionID) { LogManager.i(this, "Message from another instance received on SessionID " + sessionID + ". Restarting OTR session for this user."); newAction(sessionID.getAccountID(), sessionID.getUserID(), null, ChatAction.otr_unreadable); } @Override public void multipleInstancesDetected(SessionID sessionID) { LogManager.i(this, "Multiple instances detected on SessionID " + sessionID); // since this is not supported, we don't need to do anything } public void onContactUnAvailable(AccountJid account, UserJid user) { Session session = sessions.get(account.toString(), user.toString()); if (session == null) { return; } if (session.getSessionStatus() == SessionStatus.ENCRYPTED) { try { LogManager.i(this, "onContactUnAvailable. Refresh session for " + user); session.refreshSession(); } catch (OtrException e) { LogManager.exception(this, e); } } } }